Your product manager wants “real-time updates.” Before you reach for WebSocket, stop. The right choice depends on your data flow direction, scale requirements, and infrastructure. Picking the wrong protocol means either over-engineering a simple notification feed or under-engineering a chat system that collapses at scale.
This guide breaks down the three main approaches with real code, honest tradeoffs, and a decision framework you can actually use.
Why HTTP Request-Response Falls Short
Standard HTTP is a pull model — the client asks, the server responds. For real-time features, this means the client must keep asking “anything new?” repeatedly.
// Naive polling: wasteful and laggy
setInterval(async () => {
const res = await fetch('/api/notifications');
const data = await res.json();
updateUI(data);
}, 5000); // 5-second delay + wasted requests when nothing changed
This approach wastes bandwidth (most responses return “nothing new”), introduces latency (up to 5 seconds before you see an update), and hammers your server with unnecessary requests.
Long Polling: The Simplest Upgrade
Long polling flips the script: the client sends a request, but the server holds it open until there is new data to send. Once the client receives a response, it immediately sends another request.
How It Works
// Client-side long polling
async function longPoll() {
try {
const res = await fetch('/api/events?since=lastEventId', {
signal: AbortSignal.timeout(30000) // 30s timeout
});
const data = await res.json();
handleNewData(data);
} catch (err) {
// Timeout or error: wait briefly, then retry
await new Promise(r => setTimeout(r, 1000));
}
longPoll(); // Immediately reconnect
}
// Server-side (Node.js/Express)
app.get('/api/events', async (req, res) => {
const since = req.query.since;
// Check for new data, wait up to 25 seconds
const data = await waitForNewEvents(since, 25000);
if (data) {
res.json(data);
} else {
res.status(204).end(); // No new data, client will retry
}
});
When Long Polling Makes Sense
- You need maximum browser and proxy compatibility
- Updates are infrequent (less than once per second)
- You cannot use WebSocket due to corporate proxy restrictions
- The implementation must be dead simple
Drawbacks
- Each response requires a new HTTP connection (overhead)
- Slight latency gap between response and next request
- Server must hold many open connections simultaneously
- Not suitable for high-frequency updates
Server-Sent Events (SSE): The Underrated Middle Ground
SSE provides a persistent, one-way channel from server to client over standard HTTP. The browser natively supports it via the EventSource API, with automatic reconnection built in.
Server Implementation
// Node.js/Express SSE endpoint
app.get('/api/stream', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
// Send a comment every 15s to keep the connection alive
const heartbeat = setInterval(() => {
res.write(': heartbeat\n\n');
}, 15000);
// Send events as they happen
const onNewOrder = (order) => {
res.write(`event: new-order\n`);
res.write(`data: ${JSON.stringify(order)}\n\n`);
};
eventEmitter.on('order:created', onNewOrder);
req.on('close', () => {
clearInterval(heartbeat);
eventEmitter.off('order:created', onNewOrder);
});
});
Angular Client with Signals
// notification.service.ts
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class NotificationService {
notifications = signal<Notification[]>([]);
connected = signal(false);
private eventSource: EventSource | null = null;
connect() {
this.eventSource = new EventSource('/api/stream');
this.connected.set(true);
this.eventSource.addEventListener('new-order', (event) => {
const order = JSON.parse(event.data);
this.notifications.update(list => [order, ...list]);
});
this.eventSource.onerror = () => {
this.connected.set(false);
// EventSource automatically reconnects!
};
this.eventSource.onopen = () => {
this.connected.set(true);
};
}
disconnect() {
this.eventSource?.close();
this.connected.set(false);
}
}
SSE Advantages
- Automatic reconnection with configurable retry interval (built into EventSource)
- Event ID tracking — resume from where you left off via
Last-Event-IDheader - Works over HTTP/2 — multiplexed connections, no head-of-line blocking
- Firewall and proxy friendly — standard HTTP, no upgrade needed
- Native browser API — no library required
SSE Limitations
- Unidirectional — server to client only (client uses regular HTTP for sending)
- Text only — no binary data (must base64 encode)
- Maximum 6 connections per domain in HTTP/1.1 (solved by HTTP/2)
WebSocket: Full-Duplex Power
WebSocket provides a persistent, bidirectional channel. After an HTTP handshake upgrade, both client and server can send messages at any time without request-response overhead.
Server Implementation
// Node.js WebSocket server (using ws library)
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
const clients = new Set();
wss.on('connection', (ws) => {
clients.add(ws);
// Heartbeat to detect dead connections
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', (data) => {
const message = JSON.parse(data);
// Broadcast to all other clients
clients.forEach(client => {
if (client !== ws && client.readyState === 1) {
client.send(JSON.stringify(message));
}
});
});
ws.on('close', () => { clients.delete(ws); });
});
// Ping every 30 seconds to detect stale connections
setInterval(() => {
wss.clients.forEach(ws => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
Angular Chat Component
// chat.service.ts
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class ChatService {
messages = signal<ChatMessage[]>([]);
connectionState = signal<'connecting' | 'open' | 'closed'>('closed');
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
connect(roomId: string) {
this.connectionState.set('connecting');
this.ws = new WebSocket(`wss://api.example.com/chat/${roomId}`);
this.ws.onopen = () => {
this.connectionState.set('open');
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
this.messages.update(list => [...list, msg]);
};
this.ws.onclose = () => {
this.connectionState.set('closed');
this.reconnect(roomId);
};
}
send(content: string) {
this.ws?.send(JSON.stringify({ content, timestamp: Date.now() }));
}
private reconnect(roomId: string) {
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.reconnectAttempts++;
setTimeout(() => this.connect(roomId), delay);
}
}
Head-to-Head Comparison
| Feature | Long Polling | SSE | WebSocket |
|---|---|---|---|
| Direction | Server → Client | Server → Client | Bidirectional |
| Protocol | HTTP | HTTP | WS (upgrade from HTTP) |
| Auto Reconnect | Manual | Built-in | Manual |
| Binary Support | Yes | No (text only) | Yes |
| HTTP/2 Compatible | Yes | Yes (multiplexed) | No (separate connection) |
| Proxy Friendly | Very | Yes | Sometimes problematic |
| Latency | Medium | Low | Lowest |
| Connections per Server | ~10K | ~50K | ~50K |
| Memory per Connection | ~10KB | ~5KB | ~8KB |
| Complexity | Low | Low | Medium-High |
Decision Framework
Use this flowchart to pick the right approach:
- Does the client need to send frequent messages? → WebSocket (chat, gaming, collaborative editing)
- Is it server-to-client only? → SSE (notifications, live feeds, dashboards, stock tickers)
- Do you need maximum compatibility with zero dependencies? → Long Polling (legacy systems, restrictive proxies)
- Is it a live sports score or news feed? → SSE (one-way, auto-reconnect, event types)
- Is it a multiplayer game or real-time collaboration? → WebSocket (bidirectional, low latency, binary frames)
Scaling Considerations
All three approaches face the same fundamental challenge at scale: sticky sessions. When a client connects to Server A, that server holds the connection state. If an event originates on Server B, it must reach Server A to deliver to the client.
Solution: Redis Pub/Sub
// Every server subscribes to a Redis channel
import Redis from 'ioredis';
const redisSub = new Redis();
const redisPub = new Redis();
// When a new event happens on any server:
redisPub.publish('notifications', JSON.stringify(event));
// Every server listens and forwards to its connected clients:
redisSub.subscribe('notifications');
redisSub.on('message', (channel, data) => {
const event = JSON.parse(data);
connectedClients.forEach(client => client.send(data));
});
Common Mistakes
- No heartbeat mechanism: Connections silently die behind NATs and proxies. Always implement ping/pong (WebSocket) or comment-based heartbeats (SSE).
- Memory leaks from unclosed connections: Always clean up event listeners and remove clients from tracking sets on disconnect.
- Missing reconnection with backoff: Network blips happen. Implement exponential backoff (1s, 2s, 4s, 8s) with a maximum delay cap.
- Using WebSocket when SSE is enough: If your data only flows server-to-client, SSE is simpler, more reliable, and works better with HTTP/2.
- Not handling message ordering: Messages can arrive out of order after reconnection. Include sequence numbers or timestamps.
Key Takeaways
- SSE is the right default for most real-time features — notifications, feeds, dashboards
- WebSocket is for bidirectional needs — chat, gaming, collaboration
- Long Polling still has its place — maximum compatibility, simple infrastructure
- Always implement heartbeats and reconnection regardless of which approach you choose
- Use Redis Pub/Sub for horizontal scaling to distribute events across server instances
- Start with SSE, upgrade to WebSocket only when you hit its limitations — premature WebSocket is a common source of unnecessary complexity
The best real-time architecture is the simplest one that meets your requirements. Do not let the appeal of WebSocket trick you into over-engineering a notification bell.