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

  1. Open Network tab, filter by EventStream.
  2. Select the /events request.
  3. Click the EventStream sub-tab β€” every received frame appears here with its id, event, and data.
  4. Verify TTFB < 50 ms (time to first byte β€” the stream must open fast).
  5. Simulate disconnect: click Offline in DevTools Network throttling, then back to Online. Confirm the client reconnects and the server logs the Last-Event-ID it 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 off and proxy_read_timeout 3600s on 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 on Last-Event-ID to 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: no as a response header on SSE endpoints β€” this disables Nginx buffering even when proxy_buffering is 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.