What SSE actually is: an HTTP response that never ends

Strip away the branding and Server-Sent Events is one idea: a normal HTTP request whose response body is a stream that stays open. The client makes an ordinary GET, the server replies 200 OK with Content-Type: text/event-stream, and then, crucially, it does not send a Content-Length and does not close the connection. Instead it holds the response open and writes small UTF-8 text records into the body over seconds, minutes, or hours. Each record the client parses becomes an event it can act on.

Because it rides on a plain HTTP response, there is no handshake, no protocol upgrade, and no second port. It is the same GET your CDN, proxy, and load balancer already understand, just one that happens to last a long time. And it is deliberately unidirectional: data flows only from server to client. The client cannot push messages back up the same stream. If the client needs to talk to the server, it does what web clients always do — a separate fetch or POST. That one-way restriction is what keeps SSE simple enough to be a browser primitive: there is no frame type to negotiate, no closing handshake to get wrong, just a long text response and a parser that turns it into events.

Advertisement

The wire format: data, event, id, retry, and heartbeats

The on-the-wire format is almost aggressively simple — it is line-based UTF-8 text, and a blank line marks the end of one event. Each event is a set of field: value lines. Only a handful of fields exist:

retry: 3000

event: message
id: 42
data: hello world

id: 43
data: {"token": "Hel"}
data: {"token": "lo"}

: this is a comment / heartbeat, ignored by the client

event: userupdate
id: 44
data: {"name": "Ada"}

data: carries the payload; multiple data: lines in one event are joined with newlines, which is how you stream multi-line JSON. event: names a custom event type (defaulting to message). id: tags the event so the client can resume after a drop. retry: tells the client how many milliseconds to wait before reconnecting. A line that starts with a colon is a comment — the client ignores it — and the idiomatic use is a periodic : keep-alive heartbeat that stops idle proxies from killing a stream that has simply gone quiet. That is the entire grammar; you can produce it from any language that can write bytes to a socket.

Advertisement

The EventSource browser API

On the client, all of that parsing is done for you by a built-in object called EventSource. You give it a URL and attach handlers; the browser opens the request, parses the stream, dispatches events, and — the key part — reconnects automatically if the connection drops.

const es = new EventSource('/events');

// default 'message' events (no event: field on the wire)
es.onmessage = (e) => {
  console.log('id', e.lastEventId, 'data', e.data);
};

// a named event type from an `event: userupdate` line
es.addEventListener('userupdate', (e) => {
  const user = JSON.parse(e.data);
  render(user);
});

es.onopen  = () => console.log('connected, readyState', es.readyState); // 1 = OPEN
es.onerror = () => console.log('reconnecting…', es.readyState);         // 0 = CONNECTING

// es.close() to stop for good; readyState 2 = CLOSED

The whole surface is small: onmessage for default events, addEventListener for named ones, onopen and onerror for lifecycle, a readyState that is CONNECTING, OPEN, or CLOSED, and close() to stop. Notice what is absent: there is no send(). EventSource is receive-only by design. You never write the reconnection loop, the backoff, or the parser — the browser owns all of it, which is exactly why so little application code is needed to consume a robust real-time feed.

Automatic reconnect and Last-Event-ID: reliability you get for free

This is the feature that earns SSE its keep, and the one most people do not know is there. When an SSE connection drops — a dropped packet, a laptop lid, a proxy recycling a connection — EventSource does not surface an error you have to recover from. It waits the interval the server advertised with retry: (defaulting to a few seconds), then reconnects on its own. Your onmessage handler simply keeps firing.

Resumption is the other half. Every time the client receives an event carrying an id:, it remembers that value as the last event ID. When it reconnects, it automatically sends a request header, Last-Event-ID: 43, telling the server the last thing it saw. A server that keeps a short backlog can then replay everything after id 43, so the client resumes with no gap and no duplicates — the effect the lab in this article demonstrates directly. Contrast this with a raw WebSocket: the socket gives you a bidirectional pipe and nothing else. Reconnection, exponential backoff, a resume cursor, detecting and de-duplicating replayed messages — you design and write every bit of it yourself, and it is a classic source of subtle bugs. With SSE the protocol already made those decisions and the browser already implements them; you only have to honour Last-Event-ID on the server.

SSE vs WebSocket: pick the shape of your data flow

SSE and WebSocket are the two ‘real-time’ browser transports, and the choice between them is not about speed — it is about the shape of your traffic. WebSocket is a full-duplex pipe created by upgrading an HTTP connection; SSE is a one-way stream that is just a long HTTP response.

DimensionSSEWebSocket
DirectionOne-way, server → clientFull-duplex, both ways
ProtocolPlain HTTP response (text/event-stream)Upgrade handshake to the ws:// protocol
PayloadUTF-8 text onlyText or binary frames
ReconnectAutomatic, with Last-Event-ID resumeYou build it yourself
Infra friendlinessWorks through ordinary proxies, CDNs, HTTP/2Proxies/LBs often need explicit upgrade support
Client APIBuilt-in EventSourceBuilt-in WebSocket, but more to manage

The honest rule of thumb: if the browser mostly receives and only occasionally sends (which it can do with a normal request), reach for SSE — it is simpler, more infrastructure-friendly, and gives you reconnection for free. If the client and server are in a genuine back-and-forth at high frequency — a multiplayer game, a collaborative cursor, a chat where typing latency matters — or you need to move binary data, WebSocket is the right tool and its extra machinery earns its cost. Many production systems use both: SSE for the server’s firehose, plain POST for the client’s occasional writes.

SSE vs long-polling and plain polling

Before SSE and WebSocket, the browser faked server push with polling. It is worth understanding why a held connection beats repeated requests, because the difference is stark. Plain polling asks the server ‘anything new?’ on a fixed timer — every few seconds, forever. Most of those requests return ‘no’, so you pay the full cost of a request/response round trip, headers and all, over and over, to learn nothing. Worse, your latency is bounded by the poll interval: an event that happens right after a poll waits the whole interval before the next one picks it up. Shorten the interval to cut latency and you multiply the wasted requests.

Long-polling is smarter: the client makes a request and the server holds it open until it actually has something, then responds; the client immediately re-requests. That removes the empty replies and the interval latency, but every single message still costs a fresh HTTP request/response cycle — new headers, often a new connection setup — and there is always a brief blind window between responding and re-connecting. SSE collapses all of that into one connection that stays open: the server pushes each event the instant it exists, with no per-message request overhead and no reconnect gap between messages. It is, in effect, long-polling done once and done right — which is why SSE is often described as the standardized, efficient successor to the long-poll hacks it replaced.

The six-connection limit and how HTTP/2 erases it

There is a famous foot-gun with SSE over HTTP/1.1, and knowing it will save you a baffling afternoon. Browsers cap the number of concurrent connections to a single origin — historically about six. A live SSE stream holds one of those connections open for its entire lifetime. Open a couple of SSE streams in a couple of tabs and you can burn through most of your budget; every additional tab consumes more. Once the six slots are used, all other requests to that origin stall — images, API calls, navigations — queued behind streams that never finish. Developers hit this, see a totally unrelated page hang, and have no idea SSE is the cause.

HTTP/2 (and HTTP/3) dissolve the problem. They multiplex many independent streams over a single TCP (or QUIC) connection, so a long-lived SSE response is just one stream among dozens sharing one connection rather than one of six scarce sockets. The practical guidance follows directly: serve SSE over HTTP/2. On a modern stack behind TLS this is usually automatic, and it turns SSE from something you ration into something you can open freely. If you are still forced onto HTTP/1.1, treat open streams as a scarce resource: one shared stream per tab, closed when the tab is hidden, rather than one per widget on the page.

Server implementation essentials

Producing SSE is easy to get 90% right and easy to get subtly wrong. The essentials: send the correct headers, defeat every layer of buffering between you and the client, flush after each event, and heartbeat so idle intermediaries do not hang up. A minimal handler looks like this:

# Flask-style pseudocode
def events():
    def gen():
        last = int(request.headers.get('Last-Event-ID', 0))
        yield 'retry: 3000\n\n'
        for evt in stream_since(last):        # replay backlog, then live
            yield f'id: {evt.id}\n'
            yield f'event: {evt.type}\n'
            yield f'data: {evt.json}\n\n'    # blank line ends the event
        # periodic heartbeat when idle:
        # yield ': keep-alive\n\n'
    return Response(gen(), mimetype='text/event-stream', headers={
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive',
        'X-Accel-Buffering': 'no',            # tell nginx NOT to buffer
    })

The non-obvious lines are the ones that matter. Content-Type: text/event-stream and Cache-Control: no-cache are mandatory. Buffering is the silent killer: a web server, WSGI layer, or reverse proxy that buffers the response will hold your events until its buffer fills — so the client sees nothing for ages, then a burst — which completely defeats streaming. You must flush after every event and disable proxy buffering (for nginx, X-Accel-Buffering: no or proxy_buffering off). Finally, honour Last-Event-ID to replay the backlog, and emit a heartbeat comment every 15–30 seconds so load balancers with idle timeouts keep the connection alive.

Where SSE shines: notifications, dashboards, progress, and LLM tokens

SSE fits any feature where the server needs to push and the client mostly listens. Notifications and alerts are the canonical case: a bell that lights up, a ‘new message’ badge, a ‘someone commented’ toast — low-volume, server-originated, perfectly one-way. Live dashboards and feeds are next: metrics tickers, stock or sports scores, activity streams, a build or deploy log tailing in real time. All of these are the server continuously updating a view the user is watching, exactly SSE’s shape.

Progress bars for long-running jobs — an import, an export, a video transcode — are a beautiful fit: the client kicks off the job with a POST, then opens an SSE stream to watch percentage events roll in until ‘done’. But the use case that made SSE fashionable again is LLM token streaming. When you watch ChatGPT or a Claude response appear word by word, that is very often SSE under the hood: the model generates tokens one at a time, and the server emits each as a data: event so the UI can render them the instant they exist instead of waiting for the whole completion. The fit is perfect — a one-way stream of text chunks from server to client is precisely what SSE was designed to carry, and it is why the OpenAI-style streaming APIs speak text/event-stream.

Scaling SSE: connection pressure and pub/sub fan-out

SSE’s defining operational characteristic is that every connected client holds an open connection. Ten thousand live users means ten thousand concurrent open responses on your fleet. This is a connection-count problem, not a throughput problem, and it changes how you build. A thread-per-request server (classic blocking model) falls over quickly, because each idle stream still pins a thread; you want an async / event-driven server (async Python, Node, Go, Netty) where an idle connection costs a cheap file descriptor and a little memory rather than a whole thread.

The architectural pattern that scales is to keep the emitters stateless and move the events onto a pub/sub bus. Rather than each app server knowing which users to notify, every SSE server subscribes to a channel on Redis, Kafka, or NATS; whatever part of the system produces an event publishes it once to the bus, and every server fans it out to the clients it happens to hold. This decouples producing an event from delivering it, lets you add SSE servers horizontally behind a load balancer, and means a client can reconnect to any server and still get its stream. Sticky sessions become optional: because reconnect + Last-Event-ID lets any node replay from the shared log, you do not need a client pinned to the box it first connected to.

Gotchas: buffering, timeouts, no binary, and mobile

SSE’s simplicity hides a few sharp edges that reliably surface in production. The first and most common is response buffering, worth repeating because it defeats streaming so silently: a reverse proxy that buffers (nginx does by default) will withhold your events, so set X-Accel-Buffering: no or turn buffering off for the route. The second is idle timeouts: load balancers, proxies, and some browsers will close a connection that has sent nothing for too long, which is exactly why the periodic : keep-alive heartbeat exists — a quiet stream must still make noise.

The third is a hard limit, not a config: SSE carries only UTF-8 text. There are no binary frames. If you must send binary, you base64-encode it into data: (paying ~33% overhead) or you use WebSocket instead. The fourth bites on phones: mobile background disconnects. When a user backgrounds your web app or the phone sleeps, the OS often suspends the connection; the stream drops and only reconnects when the app is foregrounded again. The automatic reconnect and Last-Event-ID resume make this survivable — the client catches up on what it missed — but you must design your server-side backlog so that catching up is actually possible after a gap of minutes, not just seconds.