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

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:

  1. Connection drops (network blip, proxy timeout, server restart).
  2. Client waits retry ms (default ~3s, browser-defined).
  3. Client re-issues the same GET, adding Last-Event-ID: <last id it saw>.
  4. 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 call res.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

References