When to Use Server-Sent Events over WebSockets Permalink to this section
Part of SSE vs WebSockets vs HTTP Polling.
The most common signal that you picked the wrong transport is discovering your WebSocket server devotes 80 % of its code to message routing from server to client, with client-to-server messages reduced to a subscribe handshake sent once on connect. That asymmetry is a direct indicator that Server-Sent Events fit better: SSE is a unidirectional HTTP stream β the browser opens one long-lived GET request, the server pushes text/event-stream frames indefinitely, and reconnection is handled by the browser natively. No custom protocol, no ping/pong state machine, no WS upgrade negotiation to debug through a corporate proxy.
Symptom and Developer Intent Permalink to this section
Engineers reach for WebSockets as a default βreal-timeβ solution without evaluating data flow direction. The symptoms that suggest you should switch to SSE:
- Client sends one request (a subscription or filter), then only receives data.
- You wrote custom application-level heartbeat/ping logic because the WebSocket idle timeout killed connections.
- Your staging environment works but production drops connections through Nginx, HAProxy, or a CDN that does not pass WebSocket upgrades without explicit configuration.
- Mobile clients reconnect erratically; you are manually re-subscribing after each disconnect.
- You hit the browser limit of one active WebSocket per tab and need multiple independent streams from the same origin.
The developer intent is to push a stream of discrete, named events from server to client β notifications, metric snapshots, log lines, AI completion tokens, progress updates β without needing the client to send anything other than the initial HTTP request.
Root Cause Analysis Permalink to this section
Why WebSockets Add Cost Without Benefit for Unidirectional Push Permalink to this section
WebSockets perform a full HTTP Upgrade handshake (101 Switching Protocols), then maintain a full-duplex TCP connection with a custom framing layer. For strictly server-to-client flows, that framing layer is unused overhead.
| Concern | WebSocket | SSE |
|---|---|---|
| Protocol | Custom WS framing (RFC 6455) | Plain HTTP/1.1 or HTTP/2 |
| Upgrade handshake | Required (101) | None β standard GET |
| Proxy / CDN support | Requires explicit Upgrade passthrough |
Works by default |
| Browser reconnect | Manual (onclose handler) |
Built-in, spec-mandated |
| Event IDs / resume | Not in spec; must implement | id: + Last-Event-ID header |
| HTTP/2 multiplexing | One WS per TCP connection | Many streams over one TCP |
| Load balancer sticky sessions | Often required | Not required (stateless HTTP) |
| TLS termination | Standard | Standard |
| Binary payloads | Native | Base64 encode or separate endpoint |
The WS framing overhead is 2β10 bytes per frame; for JSON payloads of 200β2000 bytes that overhead is negligible. The real cost is operational: every proxy, CDN, and firewall in the chain must be explicitly configured to pass WS upgrades. SSE avoids this entirely because it is an ordinary long-lived HTTP response.
The HTTP/2 Multiplexing Argument Permalink to this section
Under HTTP/1.1, browsers allow at most 6 concurrent connections per origin. Opening 4 SSE streams consumed 4 of those 6 slots β a real constraint. Under HTTP/2, all streams share one TCP connection, eliminating per-origin connection limits. If your server supports HTTP/2 (Nginx, Caddy, Go net/http with TLS, Node.js with http2 module), SSE scales without the historical connection-count tax. See Connection-Count Trade-offs: SSE vs WebSockets for the full numbers.
Step-by-Step: Migrating from WebSockets to SSE Permalink to this section
These steps assume an existing WebSocket server-push endpoint and a browser client.
Step 1 β Audit Data Flow Direction Permalink to this section
Map every message type your WebSocket server sends and receives. If client-to-server messages reduce to one or two types (e.g., subscribe, filter), those can be moved to a plain HTTP POST sent once before the SSE connection is opened.
# Quick audit: count message types in server WS handler
grep -E "ws\.send|socket\.emit|conn\.WriteMessage" src/**/*.{js,ts,go,py} | \
grep -v "ping\|pong\|heartbeat" | wc -l
# If nearly all lines are ws.send/WriteMessage, SSE is the right fit
Step 2 β Replace the Server Endpoint Permalink to this section
Node.js / Express β before (WebSocket):
// ws-server.js β WebSocket handler (to be replaced)
wss.on('connection', (ws) => {
ws.on('message', (msg) => {
const { type, filter } = JSON.parse(msg);
if (type === 'subscribe') registerClient(ws, filter);
});
ws.on('close', () => deregisterClient(ws));
const interval = setInterval(() => ws.send(JSON.stringify(nextEvent())), 1000);
ws.on('close', () => clearInterval(interval));
});
Node.js / Express β after (SSE):
// sse-server.js
const clients = new Map(); // clientId β res
app.post('/subscribe', express.json(), (req, res) => {
// Accept subscription parameters before opening the stream
const { filter } = req.body;
req.session.filter = filter;
res.json({ ok: true });
});
app.get('/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // Nginx: disable proxy buffering per-response
});
const lastId = parseInt(req.headers['last-event-id'] ?? '0', 10);
const clientId = crypto.randomUUID();
// Send reconnect interval; resume from last acknowledged ID
res.write(`retry: 5000\n`);
if (lastId > 0) replayFrom(lastId, res); // replay missed events
clients.set(clientId, res);
req.on('close', () => clients.delete(clientId));
});
// Broadcast to all connected clients
function broadcast(eventName, data, id) {
const frame = `id: ${id}\nevent: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
for (const res of clients.values()) res.write(frame);
}
Step 3 β Replace the Client Permalink to this section
Browser β before (WebSocket):
// ws-client.js
const ws = new WebSocket('wss://api.example.com/ws');
ws.onopen = () => ws.send(JSON.stringify({ type: 'subscribe', filter: 'prod' }));
ws.onmessage = (e) => handleEvent(JSON.parse(e.data));
ws.onclose = () => setTimeout(connect, 3000); // manual reconnect
Browser β after (SSE):
// sse-client.js
// Send subscription once via REST, then open the stream
await fetch('/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filter: 'prod' }),
});
const source = new EventSource('/events', { withCredentials: true });
source.addEventListener('metric', (e) => {
handleEvent(JSON.parse(e.data));
});
source.addEventListener('error', (e) => {
// readyState 0 (CONNECTING) = browser is auto-retrying
// readyState 2 (CLOSED) = server sent no retry: or closed intentionally
if (source.readyState === EventSource.CLOSED) {
console.error('Stream closed permanently');
}
});
// No manual reconnect loop needed β EventSource retries automatically
The browser sends the Last-Event-ID header on every reconnect automatically when the server has sent id: frames, giving you exactly-once delivery guarantees via server-side replay. See Event ID & Retry Mechanism Design for how to implement the replay store.
Step 4 β Configure the Reverse Proxy Permalink to this section
Nginx buffers responses by default, which breaks SSE β the client sees no data until the buffer fills or flushes. Override this for SSE routes:
# nginx.conf β SSE location block
location /events {
proxy_pass http://backend;
proxy_http_version 1.1;
# SSE-critical: disable all buffering
proxy_buffering off;
proxy_cache off;
# Clear Connection header to support HTTP/1.1 keep-alive through proxy
proxy_set_header Connection '';
# Extend timeouts for long-lived streams (1 hour; tune to your use case)
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# Pass standard headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
For AWS ALB / CloudFront, enable HTTP/2 on the origin and set idle timeout to 3600 s. For Cloudflare, SSE works by default on the Pro plan and above with the βDisable Bufferingβ streaming toggle enabled per route.
Step 5 β Handle the HTTP/1.1 Connection-Count Limit Permalink to this section
If you must support HTTP/1.1 clients with multiple concurrent SSE streams from the same origin, multiplex logical channels over one physical SSE connection using named event types:
// Single /events endpoint, multiple named event channels
source.addEventListener('metrics', (e) => updateMetrics(JSON.parse(e.data)));
source.addEventListener('alerts', (e) => showAlert(JSON.parse(e.data)));
source.addEventListener('build-status', (e) => updateBuild(JSON.parse(e.data)));
On the server, dispatch into the correct named event field rather than opening multiple endpoints. Under HTTP/2, this constraint disappears β each EventSource instance gets its own HTTP/2 stream on the shared TCP connection.
Validation and Monitoring Permalink to this section
Verify with curl Permalink to this section
# Confirm Content-Type and streaming delivery
curl -N -H "Accept: text/event-stream" https://api.example.com/events
# Expected: lines arrive incrementally, not buffered.
# Look for:
# Content-Type: text/event-stream
# Transfer-Encoding: chunked (HTTP/1.1)
# or HTTP/2 DATA frames streaming live (use --http2 flag)
curl --http2 -N -H "Accept: text/event-stream" https://api.example.com/events
Inspect in Chrome DevTools Permalink to this section
- Open Network tab, filter by
EventStream. - Select the
/eventsrequest. - Click the EventStream sub-tab β every received frame appears here with its
id,event, anddata. - Verify TTFB < 50 ms (time to first byte β the stream must open fast).
- Simulate disconnect: click Offline in DevTools Network throttling, then back to Online. Confirm the client reconnects and the server logs the
Last-Event-IDit received.
Backend Telemetry Permalink to this section
// Track active connections and delivery latency
const activeConnections = new Gauge({ name: 'sse_active_connections' });
const eventDeliveryMs = new Histogram({ name: 'sse_event_delivery_ms', buckets: [5, 10, 25, 50, 100, 250] });
app.get('/events', (req, res) => {
activeConnections.inc();
req.on('close', () => activeConnections.dec());
// Wrap res.write to record latency
const origWrite = res.write.bind(res);
res.write = (chunk) => {
eventDeliveryMs.observe(Date.now() - chunk._ts);
return origWrite(chunk);
};
// ... rest of handler
});
Alert on: sse_active_connections dropping unexpectedly (proxy timeout), P95 delivery latency > 200 ms (buffering re-enabled), reconnect rate > 5/min per client (network instability or missing retry: directive).
β‘ Production Directives
- Set
proxy_buffering offandproxy_read_timeout 3600son every reverse proxy in the SSE request path β missing either kills streams silently. - Always send a
retry:directive (e.g.,retry: 5000\n) on connect so the browser uses your reconnect interval, not its default 3-second one. - Emit
id:frames and implement server-side event replay keyed onLast-Event-IDto guarantee no event loss across reconnects. - Use HTTP/2 on your origin and CDN layer to eliminate the 6-connection-per-origin limit for clients that open multiple SSE streams.
- Add
X-Accel-Buffering: noas a response header on SSE endpoints β this disables Nginx buffering even whenproxy_bufferingis not set in the location block.
Verification Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Can I authenticate an SSE connection the same way as a WebSocket?
Yes. SSE is a plain HTTP GET, so you authenticate with cookies (withCredentials: true on the EventSource constructor) or a query-parameter token (e.g., /events?token=...). Cookie-based auth is preferred because it inherits HttpOnly and Secure protections automatically. You cannot send custom request headers with the native EventSource API β if you need Authorization: Bearer, use fetch with a ReadableStream instead of EventSource.
Does SSE work through corporate proxies and firewalls?
Generally yes β SSE is standard HTTP, so it traverses HTTP proxies without special configuration. However, some transparent proxies buffer HTTP responses before forwarding them, which breaks streaming. Mitigation: use TLS (HTTPS), which prevents transparent proxies from buffering; the proxy must then pass the encrypted stream through. For non-TLS internal networks, negotiate proxy bypass for the /events path or add explicit proxy configuration.
What happens to queued events when a client is disconnected?
The native SSE protocol does not buffer events during disconnection. You must implement server-side buffering: store events in a ring buffer (Redis list, in-memory array) keyed by stream ID, and on reconnect replay any events with IDs greater than Last-Event-ID. Without this, the client misses events that occurred during the gap. See Idempotent Event ID Generation for monotonic ID strategies that make replay safe and deterministic.
Is SSE suitable for AI completion token streaming?
Yes β this is one of the highest-traffic SSE use cases today. LLM providers stream completion tokens as data: lines, often using event: delta for incremental tokens and data: [DONE] as a sentinel. Token payloads are small (1β20 bytes each), latency requirements are tight (<50 ms per chunk), and the flow is strictly server-to-client β exactly the profile where SSE outperforms WebSockets.
How do I handle SSE in environments that do not support EventSource (e.g., IE11, some Node.js versions)?
For browsers: use the EventSource polyfill (event-source-polyfill on npm) which emulates the API via XHR chunked responses. For Node.js server-side consumption (e.g., a BFF fetching an upstream SSE): use fetch with response.body as a ReadableStream, or the eventsource npm package. In Deno and modern Bun, the native EventSource global is available.