Skip to content

Server-Sent Events (SSE)

Not every push use case needs WebSocket's full bidirectional channel. A build pipeline streaming log lines to a dashboard, a live score feed, an AI chat interface streaming a model's response token by token — in every one of these, the server has things to say and the client has nothing to say back, beyond the initial "I'd like updates, please." Standing up a stateful, full-duplex WebSocket connection for a stream that only ever flows one way is more protocol than the job requires. Server-Sent Events, standardized as part of the WHATWG HTML specification, solves the same one-directional problem with something much closer to plain HTTP.

The core idea: a response that never finishes

An ordinary HTTP response has a body of some known or eventually-terminated length, and once the last byte arrives the connection is either closed or returned to the pool for reuse. SSE's trick is to have the server simply never finish sending the response body. The client makes one normal GET request, the server replies with a 200 OK and a special content type, and then keeps the same TCP connection open indefinitely, writing a new chunk of text to the response body every time it has something new to say.

GET /events HTTP/1.1
Host: example.com
Accept: text/event-stream
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

data: {"status": "build started"}

data: {"status": "compiling", "progress": 12}

data: {"status": "compiling", "progress": 47}

data: {"status": "build complete"}

The server never sends a Content-Length header, because it genuinely doesn't know in advance how much data the stream will eventually carry — this is the same chunked transfer situation HTTP already has a mechanism for. Each data: ... line followed by a blank line is one event; the client's browser parses these as they arrive and delivers each one to application code the instant its terminating blank line shows up, without waiting for the connection to close.

Reading it from the browser: EventSource

Browsers expose SSE through a purpose-built API, EventSource, rather than making a developer manage the raw chunked response manually:

const stream = new EventSource("/events");

stream.onmessage = (event) => {
  const update = JSON.parse(event.data);
  console.log(`Build status: ${update.status}`);
};

stream.onerror = () => {
  console.log("Connection lost — the browser will retry automatically");
};

Calling new EventSource(...) is what actually sends the GET request with Accept: text/event-stream; everything after that is the browser feeding parsed events to onmessage as they land on the wire. Compare that to WebSocket, which requires the upgrade handshake and a persistent, symmetric connection object on both ends — EventSource is closer to fetch with a callback that keeps firing.

Automatic reconnection, and the part most examples skip

EventSource's most underrated feature is what happens when the connection drops. If the underlying TCP connection is closed for any reason — a network blip, a load balancer's idle timeout, a server restart — the browser doesn't just give up and fire onerror and stop. It automatically opens a brand-new SSE request after a short delay, with zero application code required to make that happen. WebSocket gives you none of this for free; a WebSocket client that wants automatic reconnection has to implement its own retry loop.

Where SSE reconnection gets genuinely useful is the id: field and the Last-Event-ID header, which together solve the "what did I miss while disconnected" problem:

id: 42
data: {"status": "compiling", "progress": 47}

id: 43
data: {"status": "build complete"}

Each event can carry an id, and the browser remembers the last one it successfully received. When it reconnects after a drop, it automatically adds a Last-Event-ID: 43 header to the new request, letting the server know exactly where the client's understanding of the stream left off. A server that keeps a short backlog of recent events can use that header to replay anything the client missed during the gap, instead of the client silently losing whatever happened while the connection was down. This is entirely optional — a server that ignores Last-Event-ID just resumes the live stream from wherever it currently is — but it's the mechanism that makes SSE genuinely resilient rather than just simple.

Where SSE runs into its own limit: six connections per origin

SSE inherits one specific HTTP/1.1 limitation directly, because it's built on ordinary HTTP requests: browsers cap the number of simultaneous connections to a single origin at six for HTTP/1.1. An application that opens more than six EventSource connections to the same domain — say, five browser tabs each streaming updates from the same dashboard backend — will find the seventh silently stuck, waiting for one of the other six to free up, because the browser is enforcing its own connection ceiling rather than anything the server is doing.

Tip

This specific ceiling disappears under HTTP/2, whose multiplexing lets many logical streams — including several concurrent SSE connections — share one physical TCP connection with no fixed per-origin limit. A server that expects many simultaneous SSE clients from the same browser session should be reachable over HTTP/2, not HTTP/1.1, specifically to avoid this cap.

SSE vs WebSocket: picking based on direction, not preference

The two protocols solve overlapping problems, and it's tempting to treat the choice as arbitrary. It usually isn't:

SSE WebSocket
Direction Server to client only Both directions, symmetric
Transport Plain HTTP, no upgrade HTTP upgraded to a distinct framed protocol
Reconnection Automatic, built into EventSource Application must implement it
Message format Text only (UTF-8) Text or binary frames
Proxy/firewall friendliness Very high — it's just an HTTP response High, but some older proxies mishandle the upgrade

A live chat needs both directions on one channel, so WebSocket fits it naturally. A dashboard that only receives updates, a notification feed, an LLM streaming a response token by token — all of these are server-to-client only, and the client's replies, when it has any at all (submitting a new chat prompt, acknowledging a notification), are perfectly well served by an ordinary separate HTTP POST. Reaching for WebSocket there adds a stateful, symmetric connection to solve a problem that was never symmetric in the first place.

Practical scenario: an Nginx reverse proxy that buffers the entire stream before sending anything

A team builds an SSE endpoint that streams AI-generated text token by token, tests it directly against the application server, and sees smooth, incremental output. Deployed behind an Nginx reverse proxy, the same endpoint appears to hang for several seconds and then dumps the entire response at once — defeating the whole point of streaming it.

The cause is Nginx's default proxying behavior: proxy_buffering is on by default, which means Nginx reads the entire upstream response into a buffer before forwarding any of it to the client, on the reasonable general assumption that a backend response is a normal, finite HTTP response worth buffering for efficiency. An SSE stream breaks that assumption — it's not finite, and buffering it means the client sees nothing until the buffer fills or the connection eventually ends.

location /events {
    proxy_pass http://backend;
    proxy_buffering off;
    proxy_cache off;
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding off;
}

proxy_buffering off is the fix that matters: it tells Nginx to forward each chunk to the client as soon as it arrives from the backend, instead of accumulating a buffer first. proxy_set_header Connection '' clears any Connection: close a client might send, since SSE needs the connection kept open; proxy_http_version 1.1 ensures Nginx uses a version that supports persistent connections and chunked responses toward the backend. After reloading the configuration (nginx -t to validate it first, then nginx -s reload), the same request streams incrementally again, confirmed with:

curl -N http://example.com/events

The -N flag disables curl's own output buffering, which matters here for the same reason as the Nginx setting — without it, curl would wait and print the whole response at once too, masking whether the actual fix worked.

A related trap worth naming here rather than after: teams sometimes assume any proxy or CDN sitting between server and client will simply pass a long-lived SSE stream through untouched. Buffering defaults like Nginx's above are common enough in reverse proxies, API gateways, and CDNs that "the stream works locally but not through the load balancer" is closer to the default outcome than the exception the first time SSE goes into a real deployment.

Practice exercises

  1. Using the Last-Event-ID mechanism described above, explain what a client that reconnects after a 10-second network drop actually receives, assuming the server kept a backlog of the last 50 events.
  2. A dashboard opens EventSource connections to the same origin from five separate browser tabs simultaneously over HTTP/1.1. Explain what happens to a sixth tab opened right after, and name the one infrastructure-level change that removes the limit entirely.

Both WebSocket and SSE assume the two sides are speaking to each other directly, one client and one server, over a connection either side can read and write freely. Machine-to-machine communication inside a backend system — one microservice calling another dozens of times a second — usually wants something with stricter guarantees than a raw stream: a defined method signature, typed request and response structures, and a wire format smaller than JSON over text. That's the gap the next article's protocol fills.

Sources