Introduction
Real-time communication is essential for modern web applications — chat apps, live dashboards, collaborative editing, live sports scores, multiplayer games, and notification systems. Three technologies dominate the real-time landscape: WebSockets (bidirectional, persistent), Server-Sent Events (server-to-client streaming), and Long Polling (HTTP-compatible fallback). Each has fundamentally different trade-offs in complexity, performance, scalability, and browser support.
Choosing the wrong real-time technology can lead to scalability nightmares, unnecessary complexity, or poor user experience. This guide compares all three with production-ready implementations, performance benchmarks, and decision frameworks to help you choose the right approach for your use case.
How Each Technology Works
WebSockets: Full-Duplex Communication
WebSockets establish a persistent, full-duplex TCP connection between client and server. The connection starts with an HTTP upgrade handshake — the client sends an Upgrade: websocket header, and the server responds with 101 Switching Protocols. After the handshake, both sides can send messages at any time without HTTP overhead.
The WebSocket protocol frames messages with a minimal 2-14 byte header, compared to hundreds of bytes for HTTP headers on every request. This makes WebSockets extremely efficient for high-frequency, small-message communication like real-time games or live trading platforms.
// WebSocket handshake (client-initiated)
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
// Server response
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=Server-Sent Events: Server-to-Client Streaming
SSE uses standard HTTP to deliver a stream of events from server to client. The server holds the connection open and sends events in a simple text format: each event is prefixed with data: and terminated by a double newline. The browser's EventSource API handles connection management, automatic reconnection, and event parsing.
SSE is built on HTTP, which means it works through proxies, load balancers, and firewalls without special configuration. With HTTP/2, multiple SSE streams can share a single TCP connection through multiplexing, eliminating the HTTP/1.1 connection limit issue.
// SSE wire format
data: {"type": "message", "text": "Hello"}
event: price-update
data: {"symbol": "AAPL", "price": 150.25}
event: notification
data: {"message": "New comment on your post"}
id: 12345
retry: 5000
Long Polling: HTTP-Compatible Real-Time
Long polling simulates real-time by holding an HTTP response open until the server has data to send. The client sends a request, and the server waits (typically 30-60 seconds) before responding. Once the response arrives, the client immediately sends a new request. This creates a near-real-time experience using standard HTTP.
Long polling is the universal fallback — it works everywhere HTTP works, including environments that block WebSocket upgrades and don't support SSE. The tradeoff is higher latency (one round-trip per message) and more server resources (each held connection consumes a thread or goroutine).
Comprehensive Comparison
| Feature | WebSocket | Server-Sent Events | Long Polling |
|---|---|---|---|
| Direction | Bidirectional | Server → Client | Bidirectional |
| Protocol | ws:// / wss:// | HTTP/1.1, HTTP/2 | HTTP/1.1, HTTP/2 |
| Connection | Persistent | Persistent | Repeated requests |
| Complexity | High | Low | Medium |
| Browser support | All | All (IE: polyfill) | All |
| Auto-reconnect | Manual | Built-in | Manual |
| Binary data | Yes | No (text only) | Depends |
| Max connections | ~65K per server | ~6 per domain (HTTP/1.1) | ~6 per domain |
| Firewall friendly | Sometimes blocked | Yes | Yes |
| Message overhead | 2-14 bytes | ~30 bytes | Full HTTP headers |
| Latency | Lowest | Low | Higher (round-trip) |
| Server resources | Low per connection | Low per connection | Higher per connection |
WebSocket Implementation
Server (Node.js with ws)
const WebSocket = require('ws');
const http = require('http');
const server = http.createServer();
const wss = new WebSocket.Server({ server });
// Room-based messaging
const rooms = new Map();
wss.on('connection', (ws, req) => {
const userId = new URL(req.url, 'http://localhost').searchParams.get('userId');
ws.userId = userId;
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', (data) => {
const message = JSON.parse(data);
switch (message.type) {
case 'join':
joinRoom(ws, message.room);
break;
case 'message':
broadcastToRoom(message.room, {
type: 'message',
from: userId,
text: message.text,
timestamp: Date.now(),
}, ws);
break;
}
});
ws.on('close', () => {
// Remove from all rooms
rooms.forEach((clients, room) => {
clients.delete(ws);
if (clients.size === 0) rooms.delete(room);
});
});
ws.send(JSON.stringify({ type: 'connected', userId }));
});
// Heartbeat to detect dead connections
const heartbeat = setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
function joinRoom(ws, room) {
if (!rooms.has(room)) rooms.set(room, new Set());
rooms.get(room).add(ws);
}
function broadcastToRoom(room, message, exclude) {
const clients = rooms.get(room);
if (!clients) return;
const data = JSON.stringify(message);
clients.forEach((client) => {
if (client !== exclude && client.readyState === WebSocket.OPEN) {
client.send(data);
}
});
}
server.listen(8080);Client with Reconnection and Heartbeat
class WebSocketClient {
constructor(url, options = {}) {
this.url = url;
this.handlers = new Map();
this.reconnectAttempts = 0;
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10;
this.heartbeatInterval = options.heartbeatInterval ?? 25000;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('Connected');
this.reconnectAttempts = 0;
this.startHeartbeat();
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
const handler = this.handlers.get(data.type);
if (handler) handler(data);
};
this.ws.onclose = (event) => {
this.stopHeartbeat();
if (!event.wasClean) {
this.reconnect();
}
};
this.ws.onerror = () => {
// onclose will fire after onerror
};
}
startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'ping' }));
}
}, this.heartbeatInterval);
}
stopHeartbeat() {
clearInterval(this.heartbeatTimer);
}
reconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnection attempts reached');
return;
}
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1})`);
setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, delay);
}
on(type, handler) {
this.handlers.set(type, handler);
}
send(data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
close() {
this.ws.close(1000, 'Client closing');
}
}Server-Sent Events (SSE) Implementation
Server (Node.js)
const http = require('http');
const clients = new Set();
const server = http.createServer((req, res) => {
if (req.url === '/events') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'X-Accel-Buffering': 'no', // Disable nginx buffering
});
clients.add(res);
// Send initial connection event with retry interval
res.write('retry: 3000\n');
res.write(`data: ${JSON.stringify({ type: 'connected', timestamp: Date.now() })}\n\n`);
// Send heartbeat every 15 seconds to keep connection alive
const heartbeat = setInterval(() => {
res.write(': heartbeat\n\n');
}, 15000);
req.on('close', () => {
clients.delete(res);
clearInterval(heartbeat);
});
}
if (req.url === '/broadcast' && req.method === 'POST') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
const message = body;
broadcast(message);
res.writeHead(200);
res.end('OK');
});
}
});
function broadcast(data) {
clients.forEach(client => {
client.write(`data: ${data}\n\n`);
});
}
server.listen(8080);Client with Custom Event Types
class SSEClient {
constructor(url, options = {}) {
this.url = url;
this.handlers = new Map();
this.options = options;
this.connect();
}
connect() {
this.eventSource = new EventSource(this.url);
this.eventSource.onopen = () => {
console.log('SSE connected');
if (this.handlers.has('connected')) {
this.handlers.get('connected')();
}
};
this.eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
const handler = this.handlers.get(data.type);
if (handler) handler(data);
};
this.eventSource.onerror = () => {
if (this.eventSource.readyState === EventSource.CLOSED) {
console.log('SSE connection closed permanently');
} else {
console.log('SSE error, auto-reconnecting...');
}
};
}
on(type, handler) {
this.handlers.set(type, handler);
// For named events, add a listener
if (type !== 'connected') {
this.eventSource.addEventListener(type, (event) => {
handler(JSON.parse(event.data));
});
}
}
close() {
this.eventSource.close();
}
}
// Usage
const sse = new SSEClient('/events');
sse.on('price-update', (data) => {
updatePriceDisplay(data.symbol, data.price);
});
sse.on('notification', (data) => {
showToast(data.message);
});Long Polling Implementation
Server (Node.js)
const http = require('http');
const pendingResponses = new Map(); // topic -> [res, ...]
const messageHistory = new Map(); // topic -> [messages]
const MAX_HISTORY = 100;
const TIMEOUT = 55000; // 55 seconds
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
if (url.pathname === '/poll') {
const topic = url.searchParams.get('topic') || 'default';
const lastId = parseInt(url.searchParams.get('lastId') || '0');
// Send missed messages immediately
const history = messageHistory.get(topic) || [];
const missed = history.filter(m => m.id > lastId);
if (missed.length > 0) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ messages: missed, lastId: missed[missed.length - 1].id }));
return;
}
// Hold connection open
if (!pendingResponses.has(topic)) pendingResponses.set(topic, []);
const pending = pendingResponses.get(topic);
const entry = { res, lastId };
pending.push(entry);
// Timeout after 55 seconds
const timer = setTimeout(() => {
const idx = pending.indexOf(entry);
if (idx > -1) pending.splice(idx, 1);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ messages: [], lastId }));
}, TIMEOUT);
req.on('close', () => {
clearTimeout(timer);
const idx = pending.indexOf(entry);
if (idx > -1) pending.splice(idx, 1);
});
}
if (url.pathname === '/send' && req.method === 'POST') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
const topic = url.searchParams.get('topic') || 'default';
const message = JSON.parse(body);
// Store in history
if (!messageHistory.has(topic)) messageHistory.set(topic, []);
const history = messageHistory.get(topic);
const id = (history[history.length - 1]?.id || 0) + 1;
const fullMessage = { ...message, id, timestamp: Date.now() };
history.push(fullMessage);
if (history.length > MAX_HISTORY) history.shift();
// Wake up pending connections
const pending = pendingResponses.get(topic) || [];
while (pending.length > 0) {
const { res, lastId } = pending.shift();
const missed = history.filter(m => m.id > lastId);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ messages: missed, lastId: id }));
}
res.writeHead(200);
res.end('OK');
});
}
});
server.listen(8080);Client with Error Handling
class LongPollingClient {
constructor(url, options = {}) {
this.url = url;
this.active = false;
this.handlers = new Map();
this.lastId = 0;
this.maxRetries = options.maxRetries ?? 5;
this.retryCount = 0;
}
async start() {
this.active = true;
while (this.active) {
try {
const url = `${this.url}?lastId=${this.lastId}`;
const response = await fetch(url, {
signal: AbortSignal.timeout(60000),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const { messages, lastId } = await response.json();
this.lastId = lastId || this.lastId;
this.retryCount = 0;
messages.forEach((message) => {
const handler = this.handlers.get(message.type);
if (handler) handler(message);
});
} catch (error) {
if (!this.active) break;
this.retryCount++;
const delay = Math.min(1000 * Math.pow(2, this.retryCount), 30000);
console.error(`Polling error (retry ${this.retryCount}):`, error.message);
await new Promise(r => setTimeout(r, delay));
}
}
}
on(type, handler) {
this.handlers.set(type, handler);
}
stop() {
this.active = false;
}
}Performance Benchmarks
Latency Comparison
| Metric | WebSocket | SSE | Long Polling |
|---|---|---|---|
| Connection setup | ~100ms | ~100ms | ~100ms |
| Message delivery (p50) | <1ms | ~5ms | ~50-200ms |
| Message delivery (p99) | ~5ms | ~20ms | ~500ms |
| Reconnection time | ~1-5s | ~3s (auto) | ~1-5s |
| Messages per second | 10,000+ | 1,000+ | ~100 |
WebSockets have the lowest per-message overhead because they skip HTTP headers entirely after the initial handshake. SSE has slightly higher overhead due to the text-based event format but is still very efficient. Long polling has the highest overhead because each message delivery requires a full HTTP round-trip.
Scalability Considerations
WebSockets maintain persistent connections, which means each connected client consumes a file descriptor and a small amount of memory on the server. A single Node.js server can handle 10,000-50,000 concurrent WebSocket connections depending on message frequency and payload size. Horizontal scaling requires a pub/sub layer (Redis, NATS) to broadcast messages across server instances.
SSE connections have similar resource characteristics to WebSockets on the server side. The HTTP/2 multiplexing advantage means fewer TCP connections are needed between client and server. SSE scales the same way as WebSockets — with a pub/sub layer for multi-server deployments.
Long polling is the most resource-intensive because each message delivery creates a new HTTP request/response cycle. Servers need to handle the connection churn and maintain pending response queues. Long polling can work with standard HTTP load balancers without sticky sessions, which is an advantage in some deployments.
When to Use Each Technology
Use WebSockets When:
- You need bidirectional communication (chat, gaming, collaborative editing)
- You need ultra-low latency (real-time games, financial trading platforms)
- You need to send binary data (file transfer, audio/video streaming)
- You're building a high-frequency update system (100+ messages/second)
- You control the server infrastructure and can handle WebSocket upgrades
Use SSE When:
- You only need server-to-client updates (notifications, live feeds, dashboards)
- You want automatic reconnection built into the browser API
- You need HTTP/2 multiplexing to reduce connection overhead
- You want simpler implementation with standard HTTP tooling
- You need events to work through corporate proxies and CDNs
Use Long Polling When:
- You need maximum compatibility with legacy infrastructure
- Your network blocks WebSocket upgrades and SSE
- Updates are infrequent (every few seconds or longer)
- You're migrating gradually from a traditional polling architecture
- You need to work behind restrictive firewalls that only allow standard HTTP
Hybrid Approach: Progressive Enhancement
Production systems often implement progressive enhancement — starting with the best transport and falling back gracefully:
class RealTimeClient {
constructor(config) {
this.config = config;
this.transport = null;
this.transportType = null;
}
async connect() {
const transports = [
{ type: 'websocket', enabled: this.config.enableWebSocket, factory: () => new WebSocketClient(this.config.wsUrl) },
{ type: 'sse', enabled: this.config.enableSSE, factory: () => new SSEClient(this.config.sseUrl) },
{ type: 'longpoll', enabled: true, factory: () => new LongPollingClient(this.config.pollUrl) },
];
for (const { type, enabled, factory } of transports) {
if (!enabled) continue;
try {
this.transport = factory();
this.transportType = type;
console.log(`Using ${type} transport`);
return;
} catch (e) {
console.log(`${type} failed:`, e.message);
}
}
}
on(type, handler) {
this.transport?.on(type, handler);
}
send(data) {
if (this.transportType === 'websocket') {
this.transport.send(data);
} else {
// SSE and long polling use HTTP POST for client-to-server messages
fetch('/api/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
}
}
}Production Deployment Considerations
Load Balancing
WebSockets require sticky sessions or a shared pub/sub layer when running behind a load balancer. Nginx, HAProxy, and cloud load balancers all support WebSocket proxying, but you must configure the upgrade headers and timeout settings correctly.
# Nginx WebSocket proxy configuration
location /ws {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400; # 24 hours
proxy_send_timeout 86400;
}Monitoring and Debugging
Monitor your real-time connections with these metrics:
- Active connections per server instance
- Message throughput (messages/second in and out)
- Connection duration (average, p95, p99)
- Reconnection rate (frequent reconnections indicate problems)
- Error rate per transport type
Use browser DevTools Network tab to inspect WebSocket frames, SSE events, and long polling requests. The WS tab shows individual messages with timestamps and payload sizes.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| No reconnection logic | Users disconnected permanently | Implement exponential backoff |
| SSE on HTTP/1.1 | Connection limit (6 per domain) | Use HTTP/2 or connection sharing |
| WebSocket without heartbeat | Zombie connections consume resources | Implement ping/pong every 25-30s |
| No fallback transport | Blocked by corporate firewalls | Use hybrid approach with progressive enhancement |
| Missing CORS headers | SSE fails cross-origin | Set Access-Control-Allow-Origin |
| No message ordering | Out-of-order delivery | Include sequence numbers in messages |
| Memory leak on reconnect | Event listeners accumulate | Clean up old listeners before reconnecting |
| No connection timeout | Long polling hangs indefinitely | Set explicit timeout (55-60s) |
Conclusion
The choice between WebSockets, SSE, and long polling depends on your communication pattern, latency requirements, and infrastructure constraints. WebSockets are the best choice for bidirectional, low-latency communication. SSE is the best choice for server-to-client streaming with minimal complexity. Long polling is the universal fallback that works everywhere.
Key takeaways:
- Start with SSE if you only need server-to-client updates — it's the simplest and most reliable option
- Use WebSockets for bidirectional communication, low latency, or binary data transfer
- Implement fallbacks — start with the best transport and degrade gracefully
- Always implement heartbeat and reconnection regardless of transport choice
- Use HTTP/2 for SSE to avoid connection limits
- Monitor active connections and message throughput in production
- Consider managed services (Pusher, Ably, Socket.io) if you don't want to manage WebSocket infrastructure yourself