SSE vs WebSockets vs HTTP Polling Permalink to this section
Part of SSE Protocol Fundamentals & Architecture.
Selecting the wrong real-time transport costs you throughput, money, and ops complexity. SSE, WebSockets, and HTTP polling are not interchangeable — they make different trade-offs on connection directionality, protocol overhead, proxy compatibility, and horizontal scalability. This guide gives you the mechanism, wire-level details, concrete server code, and a decision matrix to make the call in production.
How Each Transport Works Permalink to this section
Server-Sent Events Permalink to this section
SSE is defined in the WHATWG HTML Living Standard §9.2. The client opens a plain HTTP/1.1 (or HTTP/2) GET request with Accept: text/event-stream. The server responds with Content-Type: text/event-stream, Cache-Control: no-cache, and keeps the connection open, writing newline-delimited chunks:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no
id: 1
event: price-update
data: {"symbol":"BTC","price":67340.12}
id: 2
data: {"symbol":"ETH","price":3812.44}
retry: 5000
Each message is terminated by a blank line (\n\n). The id field becomes the Last-Event-ID header on any reconnect, enabling the server to resume delivery. See Understanding the Event Stream Format for the full field grammar.
WebSockets Permalink to this section
WebSockets (RFC 6455) start with an HTTP/1.1 101 Switching Protocols upgrade handshake, then the connection becomes a raw framed binary/text channel — HTTP is completely replaced. The browser sends a Sec-WebSocket-Key header; the server replies with Sec-WebSocket-Accept. After the handshake, both sides can send frames at any time with a 2–10 byte overhead per frame.
GET /ws HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
HTTP Polling (Short and Long) Permalink to this section
Short polling: client fires a request every N seconds. The server returns immediately with whatever events are queued.
Long polling: client fires a request; the server holds it open (up to a timeout) until an event appears, then responds. The client immediately re-sends the request. This approximates push, but each cycle tears down and re-establishes a connection, consuming a thread/coroutine on the server per waiting client.
Transport Decision Matrix Permalink to this section
| Criterion | SSE | WebSockets | HTTP Polling |
|---|---|---|---|
| Directionality | Server → Client | Full-duplex | Client → Server pull |
| Protocol | HTTP (stays HTTP) | HTTP upgrade → TCP frames | HTTP |
| Handshake overhead | 1 round-trip | 1 round-trip + upgrade | 1 round-trip per poll |
| Browser reconnect | Automatic (EventSource) | Manual | N/A (stateless) |
Last-Event-ID resume |
Native | Not in spec | Custom cursor param |
| Proxy / CDN transparent | Yes (with buffering off) | Needs Upgrade passthrough |
Yes |
| HTTP/2 multiplexed | Yes | No (separate TCP) | Yes |
| Binary frames | No (text only) | Yes | Yes (any content-type) |
| Min client-side latency | ~0 ms (streaming) | ~0 ms | Poll interval |
| Connection count (browser) | 6/origin (HTTP/1.1) | Unlimited | Unlimited |
| Auth via cookies | Yes | Yes (handshake only) | Yes |
| Good for | Feeds, logs, AI tokens | Chat, games, collab | Low-frequency, legacy |
Server-Side Implementation Permalink to this section
Node.js SSE Endpoint Permalink to this section
import http from 'http';
const clients = new Map(); // connectionId → response
http.createServer((req, res) => {
if (req.url === '/stream') {
// Mandatory headers — without these, proxies may buffer or cache
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // disable Nginx buffering
});
res.flushHeaders(); // send headers immediately
const id = crypto.randomUUID();
clients.set(id, res);
// Heartbeat prevents proxy and NAT 60-s idle timeout
const heartbeat = setInterval(() => {
res.write(': heartbeat\n\n'); // comment line; EventSource ignores it
}, 25_000);
req.on('close', () => {
clearInterval(heartbeat);
clients.delete(id);
});
// Deliver the Last-Event-ID backlog if client is resuming
const lastId = req.headers['last-event-id'];
if (lastId) replayFrom(lastId, res);
return;
}
res.writeHead(404).end();
}).listen(3000);
// Broadcast helper
function broadcast(event, data, id) {
const chunk = `id: ${id}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const res of clients.values()) res.write(chunk);
}
WebSocket Server (Node.js ws library) Permalink to this section
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 3001 });
wss.on('connection', (ws, req) => {
// Application-level ping: detect half-open connections
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', (data) => {
const msg = JSON.parse(data);
// handle bidirectional command from client
handleCommand(msg, ws);
});
ws.on('close', (code, reason) => {
console.log('WS close', code, reason.toString());
});
});
// Heartbeat interval — terminates connections that miss a pong
const heartbeat = setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) { ws.terminate(); return; }
ws.isAlive = false;
ws.ping(); // triggers pong from live clients
});
}, 30_000);
wss.on('close', () => clearInterval(heartbeat));
Client-Side Consumption Permalink to this section
SSE with EventSource Permalink to this section
// EventSource auto-reconnects; use close() to stop permanently
const es = new EventSource('/stream', { withCredentials: true });
es.addEventListener('price-update', (e) => {
const { symbol, price } = JSON.parse(e.data);
updateUI(symbol, price);
});
es.addEventListener('error', (e) => {
if (e.target.readyState === EventSource.CLOSED) {
console.warn('SSE connection closed permanently');
}
// readyState CONNECTING means browser is already retrying
});
// Manual teardown on logout / unmount
function disconnect() { es.close(); }
The browser automatically sends Last-Event-ID on each reconnect, so you never lose events as long as the server tracks them. For reconnect-interval control, emit retry: 5000 from the server (milliseconds). See Event ID & Retry Mechanism Design for advanced cursor patterns.
WebSocket Client Permalink to this section
function connectWS(url) {
const ws = new WebSocket(url);
let reconnectDelay = 1000;
ws.addEventListener('open', () => {
reconnectDelay = 1000; // reset on success
console.log('WS connected');
});
ws.addEventListener('message', (e) => {
const msg = JSON.parse(e.data);
dispatch(msg); // e.g. Redux dispatch
});
ws.addEventListener('close', (e) => {
if (e.code !== 1000) { // 1000 = normal closure, don't retry
const jitter = Math.random() * 500;
setTimeout(() => connectWS(url), reconnectDelay + jitter);
reconnectDelay = Math.min(reconnectDelay * 2, 30_000); // cap at 30 s
}
});
return ws;
}
Unlike EventSource, the WebSocket API has no automatic reconnect. You must implement exponential backoff with jitter. WebSocket close code 1006 means abnormal closure (often a network drop) — always reconnect on it.
HTTP Long-Poll Client Permalink to this section
async function longPoll(cursor) {
while (true) {
try {
const res = await fetch(`/api/events?after=${cursor}`, {
signal: AbortSignal.timeout(60_000), // server hold timeout
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { events, nextCursor } = await res.json();
events.forEach(dispatch);
cursor = nextCursor;
} catch (err) {
// exponential backoff on failure
await sleep(Math.min(2 ** retries++ * 500, 30_000));
}
}
}
Long-polling is the correct fallback for environments that block Upgrade headers or strip text/event-stream. For polyfill strategies covering EventSource gaps, see Browser Support & Polyfill Strategies.
Edge Cases & Network Interference Permalink to this section
Proxies and intermediaries are the primary failure vector for persistent connections.
Proxy Buffering Permalink to this section
HTTP/1.1 intermediaries (Nginx, Varnish, AWS ALB, corporate Squid proxies) may buffer response bodies until the connection closes. This silently kills SSE — the client receives no events until the stream ends.
# Nginx: disable buffering for SSE endpoint
location /stream {
proxy_pass http://upstream;
proxy_http_version 1.1;
proxy_set_header Connection ''; # empty = keep-alive on upstream
proxy_buffering off; # critical
proxy_cache off;
proxy_read_timeout 86400s; # match max stream lifetime
add_header X-Accel-Buffering no; # propagate to nested proxies
}
Also send X-Accel-Buffering: no as a response header from the application — Nginx respects this even without static config.
CDN Stripping Permalink to this section
CDNs (Cloudflare, CloudFront) default to buffering responses under ~32 KB before forwarding. For SSE, you need streaming mode enabled:
- Cloudflare: enable “Railgun” or use Workers for streaming; set
Cache-Control: no-storeso the edge does not buffer. - CloudFront: use an Origin Response Policy with
EnableAcceptEncodingGzipfalse and setproxy_buffering offat the origin. - Fastly: set
beresp.do_stream = truein VCL.
WebSocket Proxy Compatibility Permalink to this section
Corporate HTTP proxies often block the Upgrade header entirely. wss:// (WebSocket over TLS port 443) succeeds far more often than ws:// on port 80 because many proxies treat port-443 traffic as an opaque TLS tunnel. If WebSocket is blocked:
- Fall back to SSE for server-push.
- Use HTTP POST for client-to-server messages (REST or fetch).
NAT Timeout and Idle Connection Drops Permalink to this section
Most NAT gateways and firewalls drop idle TCP sessions after 60–300 seconds. Mitigation:
- SSE: send a comment heartbeat every 25 s:
res.write(': ping\n\n'). - WebSocket: send a
pingframe every 30 s from the server; terminate ifpongis absent. - Long-poll: server timeout should be ≤ 55 s; client immediately retries.
Performance & Scale Considerations Permalink to this section
Connection Count and Memory Permalink to this section
| Transport | Connections per 10k clients | Memory per conn (server) | Notes |
|---|---|---|---|
| SSE (Node.js) | 10,000 persistent | ~8–20 KB (writable stream) | Goroutines cheaper in Go |
| WebSocket (Node.js) | 10,000 persistent | ~20–40 KB | Per-socket read/write buffers |
| HTTP Short-poll (5 s) | ~2,000 active at peak | Stateless — heap only during req | TLS handshake cost amortised with keep-alive |
| HTTP Long-poll | Up to 10,000 held | Same as SSE | Thread-per-request kills at scale |
For SSE and WebSocket servers, the bottleneck is file-descriptor limits long before CPU or RAM. Default Linux ulimit -n is 1,024; production servers need 65,535+. See Connection Pooling for SSE Servers for tuning.
Backpressure Permalink to this section
SSE writes to http.ServerResponse (Node.js) return false when the kernel send buffer is full. You must handle this:
function safeSend(res, chunk) {
const ok = res.write(chunk);
if (!ok) {
// Slow consumer — skip or queue, do not block the event loop
res.once('drain', () => {/* retry or continue */});
}
}
For detailed backpressure strategies across transports, see Rate Limiting & Backpressure Handling.
HTTP/2 Multiplexing Permalink to this section
Under HTTP/2, each SSE stream is a separate stream within one TCP connection. The browser’s 6-connections-per-origin limit for HTTP/1.1 SSE disappears under HTTP/2 — all SSE streams share one connection. WebSockets do not benefit from HTTP/2 multiplexing (RFC 8441 “Bootstrapping WebSockets with HTTP/2” is rarely deployed). This makes SSE the better choice for multi-stream dashboards under HTTP/2.
Horizontal Scaling Permalink to this section
SSE and WebSocket servers maintain persistent in-memory state (the list of active connections). This means a fan-out event must reach every process. Options:
- Redis Pub/Sub: each server process subscribes to a channel; events published to Redis are forwarded to local clients. See Redis Pub/Sub Fan-Out for SSE.
- Sticky sessions: route a client always to the same pod. Simpler, but fails on pod restart — requires
Last-Event-IDreplay.
HTTP polling is stateless by nature — any replica serves any request.
Validation & Debugging Permalink to this section
curl Permalink to this section
# SSE: watch the raw stream
curl -N -H "Accept: text/event-stream" https://api.example.com/stream
# SSE: resume from event ID 42
curl -N -H "Accept: text/event-stream" \
-H "Last-Event-ID: 42" \
https://api.example.com/stream
# WebSocket: verify upgrade (requires websocat)
websocat wss://api.example.com/ws
# Long-poll: single request timing
curl -w "\nTotal: %{time_total}s\n" https://api.example.com/events?after=0
Browser DevTools Permalink to this section
- Open Network tab, filter by
EventStream(Chrome) orOther(Firefox). - Click the SSE request → EventStream sub-tab to see each message with its
id,event, anddata. - For WebSockets, click the WS request → Messages tab; ↑ is client→server, ↓ is server→client.
- Check Response Headers to confirm
Content-Type: text/event-streamand absence ofTransfer-Encoding: chunkedfrom buffering proxies (should be absent orchunkedonly at origin).
Structured Logging Permalink to this section
// Log every SSE connection with metadata for ops visibility
const connected = (req) => ({
event: 'sse.connected',
clientIp: req.headers['x-forwarded-for'] ?? req.socket.remoteAddress,
lastEventId: req.headers['last-event-id'] ?? null,
userAgent: req.headers['user-agent'],
timestamp: new Date().toISOString(),
});
const disconnected = (id, duration) => ({
event: 'sse.disconnected',
connectionId: id,
durationMs: duration,
timestamp: new Date().toISOString(),
});
Track sse.connected, sse.disconnected, and sse.event_sent metrics in your APM. Alert on p99 connection duration dropping below expected stream lifetime (indicates proxy resets), or retry rate spiking above 5% of active sessions.
⚡ Production Directives
- Set
proxy_buffering offandproxy_read_timeout 86400sin Nginx for all SSE paths — buffering is the #1 silent failure mode. - Send a comment heartbeat every 25 s on SSE connections and an application-level ping every 30 s on WebSockets to prevent NAT and firewall idle-connection drops.
- Raise
ulimit -nto at least 65,535 on SSE/WebSocket servers; the file-descriptor ceiling is hit long before CPU or RAM at scale. - Implement exponential backoff with jitter on WebSocket reconnection;
EventSourcehandles this automatically for SSE but itsretryinterval should be set server-side via theretry:field. - Use Redis Pub/Sub or a message broker for fan-out when running more than one SSE/WebSocket server process; sticky sessions alone are insufficient after pod restarts.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
When should I use SSE instead of WebSockets?
Use SSE when data flows only from server to client — feeds, log tails, AI token streams, progress bars, notification banners. SSE works over plain HTTP, is proxied transparently, and reconnects automatically. Use WebSockets when you need true bidirectional messaging (chat, collaborative editing, multiplayer games) or binary frames.
Does SSE work over HTTP/2?
Yes. Under HTTP/2, each SSE endpoint is a stream on a shared TCP connection, so the browser's 6-connections-per-origin limit from HTTP/1.1 does not apply. A dashboard opening 20 SSE streams uses a single underlying TCP connection under HTTP/2. Ensure your server (Nginx, Caddy, or application TLS termination) has HTTP/2 enabled.
Can I send binary data over SSE?
No. The text/event-stream format is UTF-8 text only. For binary payloads, encode as Base64 in the data: field, or switch to WebSockets which support binary frames natively. In practice, JSON-encoded binary is sufficient for most notification and event-fan-out use cases.
Why does my SSE stream stop updating behind an AWS ALB?
AWS ALB (Application Load Balancer) has a default idle timeout of 60 seconds and buffers responses. Set the ALB idle timeout to a value larger than your maximum stream duration (up to 4000 s), and ensure your server sends a heartbeat comment every 25–30 s to keep the connection active. Also configure your target group to use HTTP/1.1 and disable stickiness if you handle Last-Event-ID replay at the application layer.
What is the browser connection limit for SSE?
HTTP/1.1: browsers allow 6 connections per origin for all HTTP (including SSE). If a user opens 6 SSE streams, the 7th tab stalls. Under HTTP/2 this limit is per-session, not per-stream, so multiplexing eliminates the practical ceiling. The fix is to serve your SSE endpoint over HTTPS with HTTP/2 enabled, or use a single EventSource with a multiplexed event channel (filter by event: type client-side).