Server-Sent Events (SSE)
Server-Sent Events is a browser standard for streaming a one-way flow of messages from server to client over a single long-lived HTTP response. The server keeps the response open and writes newline-delimited text events; the browser's built-in EventSource parses them, fires DOM events, and reconnects automatically if the connection drops.
SSE is the boring, unglamorous choice that turns out to be right surprisingly often. It's plain HTTP — so it inherits your existing auth, compression, load balancers, CDNs, and observability — and it costs a fraction of the operational complexity of a WebSocket fleet. If your data flows one direction (notifications, progress bars, live dashboards, LLM token streams), SSE is usually the correct answer.
TL;DR
- SSE streams server → client only, over one long-lived HTTP
GETwithContent-Type: text/event-stream. - The wire format is plain text:
data:,event:,id:, andretry:fields, with a blank line terminating each event. - The browser
EventSourceclient reconnects automatically and replaysLast-Event-ID, so you get resumable streams for free. - SSE is text-only and, on HTTP/1.1, limited to ~6 connections per origin — HTTP/2 removes that limit by multiplexing.
- The #1 production failure is proxy buffering (Nginx, some CDNs) silently holding your events; disable it explicitly.
- Choose WebSockets when the client must send a high rate of messages back; choose SSE for one-way streams and LLM token output.
Quick Example
A complete Node/Express endpoint plus the browser client that consumes it:
Core Concepts
The wire format
An SSE stream is UTF-8 text. Each event is a group of field: value lines terminated by a blank line. Forgetting the second \n is the most common bug in a first SSE implementation — the event sits in the parser buffer forever.
EventSource and automatic reconnection
EventSource is the built-in client. It handles the parsing, the reconnect backoff, and the resume handshake:
- Connection drops (network blip, proxy timeout, server restart).
- Client waits
retryms (default ~3s, browser-defined). - Client re-issues the same
GET, addingLast-Event-ID: <last id it saw>. - Your server reads that header and resumes from the right position.
That last step is your job. If you emit id: values but ignore Last-Event-ID on reconnect, clients silently lose every event that occurred while they were disconnected.
One-way by design
SSE has no client→server channel. That is a feature, not a gap: the client just makes ordinary POST requests for writes. A chat UI over SSE looks like POST /messages to send and an EventSource to receive — two simple, independently cacheable, independently rate-limited paths instead of one stateful socket protocol you have to invent framing for.
Connection limits and HTTP/2
Over HTTP/1.1 browsers cap concurrent connections at roughly 6 per origin, and an open SSE stream consumes one of them for its entire lifetime. Open a stream in six tabs and the seventh page hangs on its API calls. Over HTTP/2 streams are multiplexed on one TCP connection and the practical limit rises to ~100 concurrent streams. Serve SSE over HTTP/2 or HTTP/3 in production — see HTTP/3 & QUIC.
Streaming LLM Responses
Token-by-token model output is the single most common use of SSE today. Every major model API streams over SSE precisely because it's one-way, text-based, and works through ordinary HTTP infrastructure.
When you proxy that through your own backend to the browser, re-emit it as your own SSE stream and keep the provider key server-side:
💡 Tip: Always emit an explicit terminal event (
done) and callres.end(). Without it the browser can't distinguish "finished" from "stalled" and will eventually reconnect and re-stream the whole answer.
Scaling SSE
Every stream is a held-open process resource
An SSE connection occupies a socket and whatever per-connection memory your runtime allocates for as long as it lives. Event-loop runtimes (Node, Python asyncio, Go, Elixir) handle tens of thousands of idle streams comfortably. Thread-per-request stacks (classic Rails/Django WSGI, Java servlet pools) will exhaust the pool at a few hundred — run SSE endpoints on an async worker (ASGI, Puma with a dedicated pool, servlet async context) rather than the main pool.
Fan-out across instances with a pub/sub bus
Behind a load balancer, the instance holding a user's stream is rarely the instance handling their write. Publish through Redis or a broker and let each instance forward to its own locally attached clients.
Heartbeats defeat idle timeouts
Load balancers, proxies, and mobile carriers kill connections that are silent for 30–120 seconds. Send a comment line periodically; it costs three bytes and resets every idle timer in the path.
Best Practices
Disable buffering everywhere in the path
Reverse proxies buffer responses by default to improve throughput — which is exactly wrong for a stream. Set the header and the proxy config.
Also avoid compression middleware on SSE routes unless it flushes per write — a gzip buffer will hold your events until it fills. See Nginx.
Give every event a monotonic ID backed by a real log
id: is only useful if the value means something to your server on reconnect. Use a database sequence, a Kafka offset, or a Postgres xmin-style cursor — not a per-connection counter that resets to zero on restart.
Cap the resume window
Backfilling from Last-Event-ID is unbounded work if a client returns after a week. Define a retention window; if the client's ID is older, send a resync event telling the UI to refetch state from scratch.
Authenticate with cookies, not headers
EventSource cannot set custom headers — there is no way to attach Authorization: Bearer …. Use a SameSite session cookie (sent automatically), or the fetch-based streaming approach below when you need a bearer token.
You trade automatic reconnection for header control — see Authentication.
Clean up on close, always
Every timer, subscription, and database cursor opened for a stream must be released when the client disconnects. Leaked intervals on a busy SSE endpoint are a textbook slow memory leak.
Comparison
Rule of thumb: if the client sends fewer than a handful of messages per minute, SSE plus ordinary POSTs is simpler and more robust than a WebSocket.
Common Mistakes
Forgetting the blank line terminator
Embedding raw newlines in data
Leaking resources when the client vanishes
Emitting IDs you can't resume from
Opening a stream per component
FAQ
Is SSE deprecated in favor of WebSockets?
No — it's an active part of the HTML standard and its usage has grown, largely because every LLM API streams over it. The two solve different problems: SSE for one-way server push over ordinary HTTP, WebSockets for genuinely bidirectional, high-frequency traffic.
Can SSE send binary data?
Not directly; the stream is UTF-8 text. Base64-encode small binaries (accepting ~33% overhead), or better, send a URL over SSE and let the client fetch the bytes over a normal HTTP request.
Does SSE work with HTTP/2 and HTTP/3?
Yes, and it should be your default. Multiplexing removes the ~6-connections-per-origin limit that makes SSE painful on HTTP/1.1, and header compression reduces reconnect cost.
How do I authenticate an EventSource?
Cookie-based sessions work transparently because EventSource sends cookies. For bearer tokens, either use a short-lived token in the query string (it lands in access logs — treat it as sensitive and expire it in minutes) or switch to a fetch + ReadableStream client and implement reconnection yourself.
Why do my events arrive all at once instead of streaming?
Almost always buffering. Check, in order: your framework's compression middleware, proxy_buffering in Nginx, your CDN's streaming support, and whether you called flushHeaders() / the runtime's flush after each write.
How many concurrent SSE connections can one server hold?
On an async runtime, tens of thousands per instance is routine — the binding constraint is memory per connection and file-descriptor limits, not CPU. Measure your own per-connection footprint before sizing; see Scalability.
Related Topics
- WebSockets — the bidirectional alternative and when it's the better fit
- REST API — the request/response half of an SSE-based design
- Webhooks — server-to-*server* push, the sibling pattern to SSE
- Redis — pub/sub fan-out across multiple API instances
- Nginx — proxy configuration that makes or breaks streaming
- HTTP/3 & QUIC — why modern HTTP versions matter for long-lived streams
- AI APIs — the LLM streaming APIs built on SSE
- Rate Limiting — protecting long-lived connection endpoints
- Scalability — capacity planning for held-open connections