- Published on
WebSockets vs SSE vs Long Polling — Choosing Real-Time Communication in 2026
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Real-time communication powers modern apps: live notifications, collaborative editing, stock tickers, chat. But real-time comes in flavors, each with tradeoffs.
WebSockets offer full-duplex, low-latency communication but require sticky sessions. Server-Sent Events (SSE) are unidirectional from server to client but work through standard HTTP. Long polling is inefficient but works everywhere.
This guide builds production implementations of all three, so you can choose wisely.
- WebSocket Full-Duplex Communication
- SSE Unidirectional Communication
- Long Polling Fallback
- WebSocket Load Balancer Sticky Sessions
- Comparison Table
- When Polling Is Actually Fine
- Conclusion
WebSocket Full-Duplex Communication
WebSockets establish persistent TCP connections, enabling true two-way communication.
// lib/websocket.ts
import WebSocket from 'ws';
import { createServer } from 'http';
interface ClientMessage {
type: 'subscribe' | 'publish' | 'ping';
channel: string;
data?: any;
}
interface ServerMessage {
type: 'message' | 'pong' | 'error';
channel: string;
data?: any;
timestamp: number;
}
class WebSocketManager {
private wss: WebSocket.Server;
private channels = new Map<string, Set<WebSocket>>();
private clientIds = new WeakMap<WebSocket, string>();
constructor(server: any) {
this.wss = new WebSocket.Server({ server });
this.wss.on('connection', (ws: WebSocket) => {
console.log('WebSocket connected');
// Heartbeat to detect dead connections
let isAlive = true;
ws.on('pong', () => {
isAlive = true;
});
const interval = setInterval(() => {
if (!isAlive) {
return ws.terminate();
}
isAlive = false;
ws.ping();
}, 30000);
ws.on('message', (data: Buffer) => {
try {
this.handleMessage(ws, JSON.parse(data.toString()) as ClientMessage);
} catch (err) {
console.error('WebSocket message error:', err);
ws.send(
JSON.stringify({
type: 'error',
data: 'Invalid message format',
})
);
}
});
ws.on('close', () => {
clearInterval(interval);
this.handleDisconnect(ws);
});
ws.on('error', (err) => {
console.error('WebSocket error:', err);
});
});
}
private handleMessage(ws: WebSocket, message: ClientMessage) {
switch (message.type) {
case 'subscribe':
this.subscribe(ws, message.channel);
break;
case 'publish':
this.publish(message.channel, {
type: 'message',
channel: message.channel,
data: message.data,
timestamp: Date.now(),
});
break;
case 'ping':
ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
break;
}
}
private subscribe(ws: WebSocket, channel: string) {
if (!this.channels.has(channel)) {
this.channels.set(channel, new Set());
}
this.channels.get(channel)!.add(ws);
console.log(`Client subscribed to ${channel}`);
}
private publish(channel: string, message: ServerMessage) {
const subscribers = this.channels.get(channel);
if (!subscribers) return;
subscribers.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
});
}
private handleDisconnect(ws: WebSocket) {
// Remove from all channels
for (const subscribers of this.channels.values()) {
subscribers.delete(ws);
}
}
public broadcast(channel: string, data: any) {
this.publish(channel, {
type: 'message',
channel,
data,
timestamp: Date.now(),
});
}
}
// Express integration
import express from 'express';
const app = express();
const server = createServer(app);
const wsManager = new WebSocketManager(server);
app.post('/api/broadcast/:channel', (req, res) => {
wsManager.broadcast(req.params.channel, req.body);
res.json({ sent: true });
});
server.listen(3000);
Client-side WebSocket:
// Client WebSocket connection
class WebSocketClient {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 1000;
private messageHandlers = new Map<string, Set<Function>>();
connect(url: string) {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.handleMessage(message);
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
this.ws.onclose = () => {
this.attemptReconnect(url);
};
}
private attemptReconnect(url: string) {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = Math.min(
this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
30000
);
console.log(`Reconnecting in ${delay}ms...`);
setTimeout(() => this.connect(url), delay);
}
}
subscribe(channel: string, handler: (data: any) => void) {
if (!this.messageHandlers.has(channel)) {
this.messageHandlers.set(channel, new Set());
// Send subscribe message
this.send({
type: 'subscribe',
channel,
});
}
this.messageHandlers.get(channel)!.add(handler);
}
private handleMessage(message: any) {
if (message.type === 'message') {
const handlers = this.messageHandlers.get(message.channel);
handlers?.forEach((h) => h(message.data));
}
}
send(message: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
disconnect() {
this.ws?.close();
}
}
SSE Unidirectional Communication
Server-Sent Events stream data to clients over HTTP. Simpler than WebSockets but unidirectional.
// lib/sse.ts
import { Response } from 'express';
class SSEManager {
private clients = new Set<{
res: Response;
channel: string;
id: string;
}>();
subscribe(res: Response, channel: string) {
// Set SSE headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('Access-Control-Allow-Origin', '*');
const clientId = Math.random().toString(36);
const client = { res, channel, id: clientId };
this.clients.add(client);
// Send initial connection message
res.write('data: {"type":"connected"}\n\n');
// Heartbeat to keep connection alive
const interval = setInterval(() => {
res.write(': heartbeat\n\n');
}, 30000);
// Clean up on disconnect
res.on('close', () => {
clearInterval(interval);
this.clients.delete(client);
console.log(`SSE client ${clientId} disconnected`);
});
res.on('error', (err) => {
console.error('SSE error:', err);
this.clients.delete(client);
});
return clientId;
}
publish(channel: string, data: any) {
for (const client of this.clients) {
if (client.channel === channel && client.res.writable) {
// SSE format: event, id, data (all optional)
client.res.write(`id: ${Date.now()}\n`);
client.res.write(`data: ${JSON.stringify(data)}\n\n`);
}
}
}
broadcast(data: any) {
for (const client of this.clients) {
if (client.res.writable) {
client.res.write(`id: ${Date.now()}\n`);
client.res.write(`data: ${JSON.stringify(data)}\n\n`);
}
}
}
}
// Express routes
const sseManager = new SSEManager();
app.get('/api/sse/subscribe/:channel', (req, res) => {
sseManager.subscribe(res, req.params.channel);
});
app.post('/api/sse/publish/:channel', (req, res) => {
sseManager.publish(req.params.channel, req.body);
res.json({ published: true });
});
Client-side SSE:
// Client SSE connection
class SSEClient {
private eventSource: EventSource | null = null;
subscribe(channel: string, handler: (data: any) => void) {
this.eventSource = new EventSource(`/api/sse/subscribe/${channel}`);
this.eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handler(data);
} catch (err) {
console.error('Failed to parse SSE message:', err);
}
};
this.eventSource.onerror = () => {
console.log('SSE connection lost, EventSource will auto-reconnect');
};
}
disconnect() {
this.eventSource?.close();
}
}
// Auto-reconnect handled by browser; custom backoff:
class SSEClientWithBackoff {
private eventSource: EventSource | null = null;
private maxRetries = 5;
private retries = 0;
private baseDelay = 1000;
subscribe(channel: string, handler: (data: any) => void) {
this.eventSource = new EventSource(`/api/sse/subscribe/${channel}`);
this.eventSource.onmessage = (event) => {
this.retries = 0; // Reset on successful message
handler(JSON.parse(event.data));
};
this.eventSource.onerror = () => {
this.eventSource?.close();
if (this.retries < this.maxRetries) {
const delay = this.baseDelay * Math.pow(2, this.retries);
this.retries++;
console.log(`Reconnecting in ${delay}ms...`);
setTimeout(() => this.subscribe(channel, handler), delay);
}
};
}
disconnect() {
this.eventSource?.close();
}
}
Long Polling Fallback
Long polling works everywhere but is bandwidth-inefficient.
// lib/longPolling.ts
class LongPollingManager {
private messageQueues = new Map<string, any[]>();
private clientWaiters = new Map<string, Array<(msgs: any[]) => void>>();
subscribe(channel: string, timeout = 30000): Promise<any[]> {
return new Promise((resolve) => {
// Check for pending messages
const pending = this.messageQueues.get(channel);
if (pending && pending.length > 0) {
const messages = pending.splice(0, pending.length);
return resolve(messages);
}
// Register waiter
if (!this.clientWaiters.has(channel)) {
this.clientWaiters.set(channel, []);
}
const waiters = this.clientWaiters.get(channel)!;
const timeoutId = setTimeout(() => {
waiters.splice(waiters.indexOf(resolve), 1);
resolve([]); // Empty array = no new messages
}, timeout);
const wrappedResolve = (msgs: any[]) => {
clearTimeout(timeoutId);
waiters.splice(waiters.indexOf(wrappedResolve), 1);
resolve(msgs);
};
waiters.push(wrappedResolve);
});
}
publish(channel: string, data: any) {
const waiters = this.clientWaiters.get(channel);
if (waiters && waiters.length > 0) {
// Active waiters: resolve immediately
const waiter = waiters.shift();
waiter?.([data]);
} else {
// No waiters: queue message
if (!this.messageQueues.has(channel)) {
this.messageQueues.set(channel, []);
}
this.messageQueues.get(channel)!.push(data);
}
}
}
// Express routes
const pollManager = new LongPollingManager();
app.post('/api/poll/:channel', async (req, res) => {
const messages = await pollManager.subscribe(req.params.channel, 30000);
res.json({ messages });
});
app.post('/api/publish/:channel', (req, res) => {
pollManager.publish(req.params.channel, req.body);
res.json({ published: true });
});
Client-side long polling:
// Client long polling
class PollingClient {
private polling = true;
async subscribe(channel: string, handler: (data: any) => void) {
while (this.polling) {
try {
const response = await fetch(`/api/poll/${channel}`, {
method: 'POST',
timeout: 35000, // Slightly more than server timeout
});
const { messages } = await response.json();
for (const message of messages) {
handler(message);
}
// Immediate re-poll if messages received
// Otherwise, exponential backoff
if (messages.length === 0) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
} catch (err) {
console.error('Polling error:', err);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}
stop() {
this.polling = false;
}
}
WebSocket Load Balancer Sticky Sessions
WebSocket connections are stateful. Load balancers must stick connections to same server.
# Nginx: sticky sessions for WebSocket
upstream websocket_backend {
# Hash by remote IP for sticky routing
hash $remote_addr consistent;
server 10.0.1.1:3000;
server 10.0.1.2:3000;
server 10.0.1.3:3000;
}
server {
listen 80;
location /ws {
proxy_pass http://websocket_backend;
# WebSocket upgrade headers
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Timeouts
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
}
Kubernetes sticky sessions:
# k8s-service.yaml for WebSocket
apiVersion: v1
kind: Service
metadata:
name: websocket-service
spec:
type: LoadBalancer
sessionAffinity: ClientIP # Sticky sessions by client IP
sessionAffinityConfig:
clientIP:
timeoutSeconds: 10800 # 3 hours
selector:
app: websocket-server
ports:
- protocol: TCP
port: 80
targetPort: 3000
Redis pub/sub for cross-server WebSocket communication:
// Broadcast across multiple servers via Redis
import redis from 'redis';
const pubClient = redis.createClient(process.env.REDIS_URL);
const subClient = redis.createClient(process.env.REDIS_URL);
class DistributedWebSocketManager extends WebSocketManager {
constructor(server: any) {
super(server);
// Subscribe to all channels
subClient.subscribe('*', (err, count) => {
if (err) console.error('Failed to subscribe:', err);
else console.log(`Subscribed to ${count} channels`);
});
subClient.on('message', (channel, message) => {
// Publish to local WebSocket clients
this.broadcast(channel, JSON.parse(message));
});
}
public broadcast(channel: string, data: any) {
super.broadcast(channel, data);
// Also publish to Redis for other servers
pubClient.publish(channel, JSON.stringify(data));
}
}
Comparison Table
| Feature | WebSocket | SSE | Long Polling |
|---|---|---|---|
| Latency | ~50ms | ~100ms | ~1-5s |
| Bandwidth | Low | Low | High |
| Client → Server | Native | No (HTTP POST) | Native |
| Server → Client | Native | Native | Native |
| Browser Support | Modern browsers | Modern browsers | All browsers |
| Sticky Sessions | Required | Optional | Not needed |
| Firewall Friendly | Some proxy issues | Works through HTTP | Works through HTTP |
| Complexity | Medium | Low | Low |
| Auto-reconnect | Manual | Built-in | Manual |
| Connection Overhead | 2KB handshake | HTTP headers | HTTP request/response |
When Polling Is Actually Fine
Don't over-engineer:
// Perfect use cases for polling:
// 1. Low-frequency updates (every 30+ seconds)
// 2. Limited concurrent users (<1000)
// 3. Best-effort delivery (chat history available)
// 4. Simple implementation priority
// Example: notification bell checking
setInterval(async () => {
const response = await fetch('/api/notifications?since=' + lastCheck);
const { notifications } = await response.json();
notifications.forEach((n) => showNotification(n));
lastCheck = Date.now();
}, 60000); // Poll every minute
Conclusion
Choose based on requirements:
- WebSockets: Real-time, low-latency, bi-directional (chat, collaborative tools)
- SSE: Real-time, server→client, unidirectional (notifications, feeds)
- Polling: Best-effort, simple, works everywhere (non-critical updates)
Use Redis pub/sub to scale WebSockets across multiple servers. Use sticky sessions on load balancers. Implement auto-reconnect logic in clients.
Build real-time without the real-time complexity tax.