Web Sockets vs Server-Sent Events Cheat Sheet
Compares WebSocket and Server-Sent Events with working code for both, plus guidance on which real-time approach fits your use case.
Server-Sent Events (Client + Server)
One-way streaming updates over plain HTTP.
// Clientconst events = new EventSource('/api/notifications');events.onmessage = (event) => { console.log('Default message:', event.data);};events.addEventListener('priceUpdate', (event) => { const data = JSON.parse(event.data); // custom named event console.log('Price:', data.price);});events.onerror = () => console.log('Connection lost, browser will auto-reconnect');// Server (Node/Express) — must send the right content type and flushapp.get('/api/notifications', (req, res) => { res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }); const timer = setInterval(() => { res.write(`event: priceUpdate\ndata: ${JSON.stringify({ price: Math.random() })}\n\n`); }, 2000); req.on('close', () => clearInterval(timer));});
WebSocket Equivalent
The same feature, but full-duplex.
// Clientconst socket = new WebSocket('wss://example.com/notifications');socket.onmessage = (event) => console.log('Received:', event.data);socket.onopen = () => socket.send(JSON.stringify({ type: 'subscribe', channel: 'prices' }));// Server (ws) — bidirectional, client can also push datawss.on('connection', (ws) => { ws.on('message', (msg) => console.log('Client sent:', msg.toString())); setInterval(() => ws.send(JSON.stringify({ price: Math.random() })), 2000);});
Key Differences
How the two protocols actually differ under the hood.
- Direction- SSE: server-to-client only. WebSocket: full-duplex, both directions
- Protocol- SSE runs over plain HTTP. WebSocket upgrades the connection via ws:// / wss://
- Reconnection- SSE auto-reconnects natively (EventSource); WebSocket needs manual reconnect logic
- Data format- SSE is text-only (UTF-8). WebSocket supports both text and binary frames
- Connection limits- SSE over HTTP/1.1 caps at ~6 concurrent connections per domain; HTTP/2 removes this
- Proxy friendliness- SSE, being plain HTTP, traverses proxies more reliably than a WebSocket upgrade
When to Choose Which
Matching the protocol to your data flow.
- Use SSE- One-way live feeds: notifications, stock tickers, live scores, log streaming
- Use WebSocket- Bidirectional interaction: chat apps, multiplayer games, collaborative editors
- SSE for simplicity- No extra client library needed; works with plain EventSource and standard HTTP infra
- WebSocket for binary/low-latency- Better suited for high-frequency, low-latency, or binary payloads
- Infrastructure cost- WebSocket servers hold a stateful connection per client, which complicates horizontal scaling
Heartbeat & Reconnect with Backoff
Detecting a dead connection and reconnecting without hammering the server.
let ws;let retryDelay = 1000;function connect() { ws = new WebSocket('wss://example.com/notifications'); const heartbeat = setInterval(() => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' })); }, 30000); ws.onopen = () => { retryDelay = 1000; }; // reset backoff on success ws.onclose = () => { clearInterval(heartbeat); setTimeout(connect, retryDelay); retryDelay = Math.min(retryDelay * 2, 30000); // exponential backoff, capped }; ws.onerror = () => ws.close(); // triggers onclose -> reconnect}connect();
Resuming an SSE Stream with Last-Event-ID
Letting the browser's built-in reconnect logic replay missed events after a drop.
// Server: tag every event with an id so the browser can report where it left offapp.get('/api/notifications', (req, res) => { res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }); const lastId = Number(req.headers['last-event-id']) || 0; const missed = getEventsSince(lastId); // replay anything the client missed missed.forEach((e) => res.write(`id: ${e.id}\ndata: ${JSON.stringify(e.payload)}\n\n`)); const sub = subscribeToNewEvents((e) => { res.write(`id: ${e.id}\nretry: 3000\ndata: ${JSON.stringify(e.payload)}\n\n`); }); req.on('close', () => sub.unsubscribe());});// Client: EventSource automatically sends Last-Event-ID on the reconnect requestconst events = new EventSource('/api/notifications');
Proxying WebSockets through Nginx
The Upgrade/Connection headers a reverse proxy must forward, plus sticky sessions for scaling.
upstream ws_backend { ip_hash; # sticky sessions: same client -> same backend instance server 10.0.0.1:4000; server 10.0.0.2:4000;}location /notifications { proxy_pass http://ws_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 3600s; # keep long-lived connections from being killed}
WebSocket Close Codes
Status codes carried in the close frame, useful for deciding whether to reconnect.
- 1000 Normal Closure- Clean, intentional close — don't reconnect automatically
- 1001 Going Away- Page navigating away or server shutting down
- 1006 Abnormal Closure- Connection dropped without a close frame (network loss) — the case reconnect logic must handle
- 1008 Policy Violation- Generic rejection, often used for failed auth
- 1011 Internal Error- Server hit an unexpected condition
- 4000-4999 range- Reserved for application-defined codes (e.g. 4001 = 'invalid token')
Scaling WebSockets Across Instances
A single process can't broadcast to sockets held open by sibling instances without a shared bus.
const { createClient } = require('redis');const sub = createClient();const pub = createClient();await Promise.all([sub.connect(), pub.connect()]);await sub.subscribe('prices', (message) => { // fan out to every socket connected to THIS instance for (const ws of localConnections) { if (ws.readyState === ws.OPEN) ws.send(message); }});// Any instance can publish; Redis delivers it to all subscribed instancesfunction broadcastPrice(price) { pub.publish('prices', JSON.stringify({ price }));}
If you only need server-to-client push and the client never needs to send data back over the same connection, prefer Server-Sent Events — you get automatic reconnection and simpler infrastructure for free.