Server-Sent Events Fundamentals and the EventSource Protocol
Concept
Server-Sent Events (SSE) is a W3C/WHATWG-standardized way for a server to push a stream of text updates to a client over a single, long-lived, plain HTTP connection — one direction only, server to client. The client opens a connection with the browser's built-in EventSource API (new EventSource(url)), the server responds with Content-Type: text/event-stream and keeps the connection open, and every time the server has something new to send, it writes a plain-text event to the same response body instead of closing it. The wire format is deliberately simple: newline-delimited fields — data: (the payload, can repeat for multi-line data), event: (a named event type, defaults to message), id: (an event ID the client will echo back on reconnect), and retry: (how long to wait before reconnecting, in milliseconds) — with a blank line terminating each event.
The property that distinguishes SSE from hand-rolled streaming is what the browser does for free once you call new EventSource(url): automatic reconnection. If the connection drops (network blip, server restart, load balancer timeout), the browser reconnects on its own after the retry: interval, and — critically — sends the last received event's ID back via an Last-Event-ID request header, so a server that tracks IDs can resume the stream exactly where it left off instead of the client missing events or re-receiving a full replay. WebSocket clients get none of this natively; reconnect-with-resume logic has to be hand-built on top of the raw socket API.
Because SSE is just an HTTP response that never finishes, it inherits HTTP's entire existing infrastructure for free: the same cookies and Authorization headers used for normal requests authenticate the stream with no separate handshake, standard HTTP caching/proxy semantics apply (even though the response itself isn't cacheable), and any tool that understands HTTP — load balancers, API gateways, browser devtools — can inspect it without protocol-specific support. That's the same "no special infrastructure" argument long polling makes, but SSE gets true server push instead of a request-per-update polling loop underneath.
Tradeoffs
| Technique | Direction | Implementation cost | Reconnect/resume | Infra compatibility |
|---|---|---|---|---|
| Short/long polling | Client-initiated only | Lowest | Nothing special — every poll is a fresh request | Best — plain request/response |
| SSE | Server → client only | Low — standard HTTP response, EventSource built into browsers |
Automatic, with Last-Event-ID resume built into the browser API |
Good — plain HTTP, but some proxies buffer or strip streaming responses |
| WebSockets | Full duplex | Higher — separate handshake, message framing, manual reconnect logic | Must be hand-built entirely by the application | Weakest — needs Upgrade support end-to-end; some corporate proxies strip it |
The deciding factor is almost never raw performance — it's whether the app genuinely needs the client to push data back over the same connection. If it doesn't, SSE gets nearly all of WebSockets' real-time feel (server pushes the instant data exists, no polling interval) for a fraction of the implementation and infrastructure-compatibility cost.
When to use / when not to
- Use for any one-directional server-to-client stream: LLM token-by-token streaming (OpenAI's and Anthropic's streaming completion APIs both use SSE for exactly this reason), live progress/status updates on a long-running job, live notification or activity feeds, stock tickers or dashboards that only display, never submit, over the stream.
- Don't use when the client also needs to send frequent data back over the same logical connection — chat, multiplayer, collaborative editing all need WebSockets' full duplex; layering a second HTTP connection alongside SSE just to send client→server messages adds complexity without matching WebSockets' single-connection efficiency.
- Don't use for binary data without accepting overhead — SSE's wire format is text-only, so binary payloads need base64 (roughly 33% size overhead) or a separate transport; WebSockets support binary frames natively.
- Watch the per-domain HTTP/1.1 connection limit (historically 6 per browser per origin) — a page that opens multiple SSE streams to the same origin, or a user with several tabs open to the same app, can exhaust that pool and silently starve other requests; HTTP/2's multiplexing (one TCP connection, many streams) removes this ceiling and is the practical fix, not a browser workaround.
Common pitfall
Deploying an SSE endpoint behind a reverse proxy or load balancer configured for normal request/response traffic, and having it silently buffer the entire response before forwarding it — defeating the whole point of streaming, since the client receives nothing until the connection closes or the buffer fills. Nginx buffers proxied responses by default and needs proxy_buffering off (or an explicit flush after each write) on SSE routes; similar buffering exists in some CDNs and API gateways unless streaming is explicitly enabled per-route. This fails silently in local development (no intermediary proxy) and only surfaces once the same code is deployed behind production infrastructure, which is exactly when it's hardest to notice quickly.
Engineering Lens
SSE's real value proposition isn't "cheaper WebSockets" — it's that it composes with everything HTTP already does. Reusing existing auth (cookies, bearer tokens), existing caching/proxy layers, and getting reconnect-with-resume for free from the browser removes an entire category of hand-rolled connection-management code that a WebSocket implementation has to own itself. The design question worth asking before reaching for WebSockets by default is simply: does the client ever need to push data back over this same connection? If the honest answer is no, SSE gets most of the real-time benefit for a fraction of the moving parts — and the buffering pitfall above is the one thing worth verifying explicitly in any environment between the server and the client, since it's invisible until it isn't.
Related
- Long Polling Fundamentals and When It Still Earns Its Keep
- LLM Integration Patterns: Direct SDK vs Provider Adapter vs Router
- Backpressure and Flow Control