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.

Transport comparison: SSE, WebSockets, HTTP Polling Side-by-side sequence diagrams showing the connection handshake and message flow for SSE, WebSockets, and HTTP polling. Server-Sent Events Client Server GET /stream 200 text/event-stream data: {...}\n\n data: {...}\n\n data: {...}\n\n Auto-reconnect via Last-Event-ID 1 TCP conn · server→client only Plain HTTP · CDN/proxy friendly Browser limit: 6 per origin (H1) WebSockets Client Server Upgrade: websocket 101 Switching Protocols frame → server frame → client ping frame (30s) Manual reconnect required 1 TCP conn · full-duplex Proxy upgrade required No browser conn limit HTTP Polling Client Server GET /api/updates 200 {events: []} GET /api/updates (t+5s) 200 {events: [e1, e2]} GET /api/updates (t+10s) 200 {events: []} New TCP conn each poll N conns · stateless · cacheable Works behind any proxy/CDN Latency = poll interval SSE stream WS frame / poll resp Client request
Wire-level flow for SSE, WebSockets, and HTTP polling — showing handshake, message direction, and reconnect behaviour.

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-store so the edge does not buffer.
  • CloudFront: use an Origin Response Policy with EnableAcceptEncodingGzip false and set proxy_buffering off at the origin.
  • Fastly: set beresp.do_stream = true in 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:

  1. Fall back to SSE for server-push.
  2. 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 ping frame every 30 s from the server; terminate if pong is 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-ID replay.

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

  1. Open Network tab, filter by EventStream (Chrome) or Other (Firefox).
  2. Click the SSE request → EventStream sub-tab to see each message with its id, event, and data.
  3. For WebSockets, click the WS request → Messages tab; ↑ is client→server, ↓ is server→client.
  4. Check Response Headers to confirm Content-Type: text/event-stream and absence of Transfer-Encoding: chunked from buffering proxies (should be absent or chunked only 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 off and proxy_read_timeout 86400s in 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 -n to 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; EventSource handles this automatically for SSE but its retry interval should be set server-side via the retry: 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).

Deep Dives