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-streamorUpgrade. - 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-streamCache-Control: no-cacheConnection: 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\ncomments 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
POSTwithEventSource. Usefetch+ stream for POST-based streams (LLMs). - Reconnection vs resumption are different things. Browser does the first; server has to honor
Last-Event-IDfor 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 support —
res.flush()(Express),flush()(FastAPI’sStreamingResponse),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-IDresume, plain HTTP, no special infrastructure. WebSockets are for bidirectional, high-frequency client pushes.” - “
EventSourceis GET-only and can’t set custom headers — for POST-based streams (LLM prompts), I usefetch+ a stream reader and parse SSE manually.” - “Proxy buffering kills SSE. Every reverse proxy and CDN needs explicit
no bufferingfor the stream endpoint, plus heartbeats.” - “Long polling is a legacy fallback, not a new design choice.”
Cross-references
- WebSocket comparison and reconnection: 07_websockets.md
- HTTP versions and HOL blocking: ../../backend/12_protocols/http/02_http_versions.md
- Backend SSE: ../../backend/12_protocols/sse/
Further reading
- WHATWG HTML — Server-Sent Events: https://html.spec.whatwg.org/multipage/server-sent-events.html
- MDN —
EventSource: https://developer.mozilla.org/en-US/docs/Web/API/EventSource - OpenAI streaming docs (canonical SSE consumer): https://platform.openai.com/docs/api-reference/streaming