frontend / apis data fetching / 08_server_sent_events.md

Server-Sent Events vs WebSockets vs Long Polling

5 min read source

Server-Sent Events vs WebSockets vs Long Polling

TL;DR

SSE is HTTP — a long-lived response that the server streams event: ...\ndata: ...\n\n lines down. The browser’s EventSource API reconnects automatically and supports Last-Event-ID resumption. It’s the right call for server-driven streams: notifications, live logs, build progress, LLM token streaming, slow-changing dashboards. WebSockets win when the client also pushes frequently (chat, presence, cursors). Long polling is the legacy fallback when neither is available.

Interview Q&A

Q: How does SSE work on the wire?

A: A regular HTTP GET that the server keeps open and writes to in a simple text format:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache

event: token
id: 42
data: {"text": "Hello"}

event: token
id: 43
data: {"text": " world"}

event: done
data: {}

Each message is separated by a blank line. The optional event: names the event; id: lets the client resume; data: is the payload (UTF-8 string).

Q: Show me the client side.

A:

const es = new EventSource("/api/notifications");

es.addEventListener("open", () => console.log("connected"));

es.addEventListener("message", (e) => {
  const msg = JSON.parse(e.data);   // default event name is "message"
  console.log(msg);
});

es.addEventListener("token", (e) => {
  appendToken(JSON.parse(e.data).text);
});

es.addEventListener("error", () => {
  // EventSource auto-reconnects with exponential backoff (browser-managed)
  console.warn("disconnected, will retry");
});

es.close();   // user-initiated

EventSource reconnects automatically. On reconnect it sends Last-Event-ID: <last id seen> as a header so the server can resume from the right place — this is the killer feature over a hand-rolled WebSocket.

Q: SSE vs WebSocket — one-line trade-off?

A:

SSE WebSocket
Direction server → client only bidirectional
Transport HTTP/1.1 (or HTTP/2) HTTP Upgrade to dedicated TCP
Auto-reconnect yes (browser-managed) no (manual)
Resume on reconnect yes (Last-Event-ID) manual
Auth headers yes (regular HTTP) no (workarounds — see WS file)
Goes through proxies yes (it’s HTTP) sometimes blocked / sticky required
Browser concurrent connection limit 6 per origin on HTTP/1.1 (not on HTTP/2) same
Binary no (UTF-8 text) yes
Server complexity low high (connection registry, etc.)

For server-driven streams, SSE is almost always the right answer. The 6-connection limit only bites on HTTP/1.1 — over HTTP/2 (multiplexed), it’s not an issue. See ../../backend/12_protocols/http/02_http_versions.md.

Q: When does long polling still make sense?

A: Almost never in new code, but you encounter it:

  • Legacy clients that pre-date EventSource/WebSocket (IE 11 etc.).
  • Strict firewalls that block text/event-stream or Upgrade.
  • As a fallback layer in libraries like Socket.IO.

Pattern: client sends GET /poll?since=<id>, server holds it open until something new arrives or a timeout (typically 25s, under most proxies’ 30s idle timeout), then responds. Client immediately re-requests. It’s “real-time via repeated requests” — works everywhere, but it’s heavier on the server (one request per message, full HTTP overhead per cycle).

Q: How do you stream LLM tokens to the UI?

A: SSE is the standard pattern (OpenAI, Anthropic, etc. all use it). Server emits each token (or chunk) as a data: event; client appends.

const es = new EventSource(`/api/chat?sessionId=${id}`);
es.addEventListener("token", (e) => {
  setText((t) => t + JSON.parse(e.data).delta);
});
es.addEventListener("done", () => es.close());

Caveat: EventSource only does GET, no custom request body. For LLM streaming where the request body is the prompt (POST), you can’t use EventSource directly. Use fetch + a ReadableStream reader and parse SSE manually:

const res = await fetch("/api/chat", {
  method: "POST",
  body: JSON.stringify({ prompt }),
  headers: { "Content-Type": "application/json" },
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = "";

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buf += decoder.decode(value, { stream: true });
  const events = buf.split("\n\n");
  buf = events.pop() ?? "";                     // last partial event stays in buffer
  for (const e of events) handleEvent(e);
}

That’s the manual-fetch SSE pattern — it’s what every “OpenAI-style streaming UI” actually does under the hood, because POST + custom headers don’t fit EventSource.

Q: How do you authenticate SSE?

A: Cookies work natively (same-origin). For Authorization: Bearer, EventSource does not support custom headers. Workarounds:

  • Token in URL (?token=...) — same leakage caveats as WS.
  • Cookie auth (best when same-origin).
  • Use fetch + ReadableStream (manual SSE parsing) — supports any headers.

Q: How do you cancel an SSE stream from the client?

A:

es.close();   // explicit

For manual-fetch SSE, abort the controller (see 05_abort_and_race_conditions.md).

Q: Server side — what do you need to send?

A:

  • Content-Type: text/event-stream
  • Cache-Control: no-cache
  • Connection: keep-alive (HTTP/1.1)
  • Disable response buffering at every layer (Node, your framework, any reverse proxy like nginx — proxy_buffering off;)
  • Send : heartbeat\n\n comments periodically (every ~15-30s) so proxies don’t kill the connection on idle

Without those, your stream batches up at nginx and arrives in a clump 30 seconds later.

Gotchas / edge cases

  • Proxy buffering is the #1 SSE bug. Nginx, Cloudflare, ALB — each has a config or a header to disable buffering on the stream endpoint.
  • Browser 6-connection limit on HTTP/1.1 — open SSE streams count. On HTTP/2 they multiplex over one connection; problem disappears.
  • No POST with EventSource. Use fetch + stream for POST-based streams (LLMs).
  • Reconnection vs resumption are different things. Browser does the first; server has to honor Last-Event-ID for the second.
  • Backpressure is implicit in HTTP — server writes block if client isn’t reading. Watch for slow consumers blocking your server’s event loop / worker.
  • Server frameworks need streaming supportres.flush() (Express), flush() (FastAPI’s StreamingResponse), WriteAsync + FlushAsync (.NET). Buffered responses defeat SSE.

What a senior is expected to say

  • “If the client only consumes pushes, SSE is the right call — auto-reconnect, Last-Event-ID resume, plain HTTP, no special infrastructure. WebSockets are for bidirectional, high-frequency client pushes.”
  • EventSource is GET-only and can’t set custom headers — for POST-based streams (LLM prompts), I use fetch + a stream reader and parse SSE manually.”
  • “Proxy buffering kills SSE. Every reverse proxy and CDN needs explicit no buffering for the stream endpoint, plus heartbeats.”
  • “Long polling is a legacy fallback, not a new design choice.”

Cross-references

Further reading