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.
In depth
How does SSE work on the wire?
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).
Show me the client side.
const es = new EventSource("/api/notifications");
es.addEventListener("open", () => console.log("connected"));
es.addEventListener("message", (e) => {
// default event name is "message"
const msg = JSON.parse(e.data);
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-initiatedEventSource 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.
SSE vs WebSocket — one-line trade-off?
| 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 HTTP Versions — 1.0, 1.1, 2, 3.
When does long polling still make sense?
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).
How do you stream LLM tokens to the UI?
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");
// last partial event stays in buffer
buf = events.pop() ?? "";
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.
How do you authenticate SSE?
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.
How do you cancel an SSE stream from the client?
es.close(); // explicitFor manual-fetch SSE, abort the controller (see AbortController, Request Dedup, Race Conditions).
Server side — what do you need to send?
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 4
- “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: WebSockets — Integration, Reconnection, and Real-Time UX
- HTTP versions and HOL blocking: HTTP Versions — 1.0, 1.1, 2, 3
- Backend SSE: 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