Server-Sent Events (SSE) is the quietest member of the real-time web family, and often the right one. It is not a new protocol and not a socket upgrade — it is just a single HTTP response that never ends. The server sets Content-Type: text/event-stream, keeps the connection open, and writes small text records down it whenever it has something to say. The browser exposes this through one tiny built-in object, EventSource, and — the part everyone underestimates — handles reconnection and resumption for you. SSE is strictly one-way, server to client, and that constraint is a feature: it keeps the model simple, keeps it friendly to ordinary HTTP infrastructure, and makes it a natural fit for notifications, live dashboards, progress bars, and the token streams that power modern LLM interfaces. This piece walks the whole thing: the wire format, the browser API, the automatic-reconnect story that WebSocket makes you build yourself, how SSE compares to WebSocket and to polling, the connection-limit and HTTP/2 details that bite in production, how to implement and scale it, and — with an interactive lab — when SSE is the cleanest choice and when it is not.
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.
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.
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 = CLOSEDThe 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.
| Dimension | SSE | WebSocket |
|---|---|---|
| Direction | One-way, server → client | Full-duplex, both ways |
| Protocol | Plain HTTP response (text/event-stream) | Upgrade handshake to the ws:// protocol |
| Payload | UTF-8 text only | Text or binary frames |
| Reconnect | Automatic, with Last-Event-ID resume | You build it yourself |
| Infra friendliness | Works through ordinary proxies, CDNs, HTTP/2 | Proxies/LBs often need explicit upgrade support |
| Client API | Built-in EventSource | Built-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.
See it: the SSE reconnect lab
The single feature that most sets SSE apart from a raw socket is that the browser reconnects for you and the server can resume without a gap. That is hard to appreciate from prose, so make it tangible: below, a ‘server’ streams numbered events into a scrolling client log. Press Drop connection to sever the link the way a flaky network would — then watch the client sit in reconnecting… for the advertised retry interval, reopen the request with a Last-Event-ID header, and pick up at exactly the next id. No duplicated events, no lost events, no code you had to write.
Keep an eye on the Last-Event-ID counter as you drop and resume: it never rewinds and never skips. That unbroken sequence is the whole reliability story in one number. With a bare WebSocket you would be writing the acknowledgement bookkeeping, the resume cursor, and the backoff loop yourself; with SSE the protocol and the EventSource client already agreed on how to do it. The lab is a cartoon — real reconnects involve DNS, TLS, and server-side replay from a log — but the contract it shows is exactly the one the standard guarantees.
A decision framework: when to choose SSE
Reduce the marketing and the choice among SSE, WebSocket, and polling comes down to a short honest interrogation of your traffic:
| Ask… | Lean SSE if… |
|---|---|
| Which way does data flow? | Mostly server → client; client writes are rare |
| Do you need the browser to push at high frequency? | No — a plain POST covers the occasional write |
| Is the payload text (JSON, tokens, logs)? | Yes — no binary frames required |
| Do you want reconnect + resume for free? | Yes — Last-Event-ID is built in |
| Must it pass through ordinary HTTP infra? | Yes — proxies, CDNs, and HTTP/2 handle it natively |
A row that pushes the other way is a signal, not a verdict. Genuine bidirectional, high-frequency, or binary traffic wants WebSocket, and that is a fine answer — use it deliberately rather than defaulting to it. A truly trivial, low-frequency update on a page nobody keeps open may not justify even a held connection, and a periodic poll is honestly simpler. But for the broad middle — notifications, dashboards, progress, log tails, and token streams — SSE hits a sweet spot the others miss: real push, real reliability, and almost no new infrastructure. Reaching for a WebSocket when a one-way stream would do is one of the most common overengineering mistakes in real-time web work.
Closing: the simple tool that is usually enough
Server-Sent Events is what you get when you take the ordinary HTTP response — the most battle-tested object on the web — and simply decline to end it. That modest move buys a surprising amount: real server push with no handshake, a wire format any language can emit, a built-in browser client in EventSource, and, most valuable of all, automatic reconnection with Last-Event-ID resumption that would cost you real, bug-prone code to reproduce on a raw socket. Its constraints — one-way, text-only, one held connection per stream — are the same constraints that keep it simple and keep it friendly to every proxy, CDN, and load balancer between your server and the user.
The lasting lesson is one of fit. Reach for WebSocket when you truly have a conversation to hold or bytes to move; reach for polling only when an update is so rare that a held connection is not worth it. But for the enormous class of features where the server has news and the browser wants to hear it — and that now prominently includes streaming AI responses token by token — SSE is very often the cleanest, most robust, and most under-appreciated answer. Serve it over HTTP/2, flush and heartbeat, honour the resume header, and it will quietly do exactly what you asked, for hours at a time, without you writing a reconnection loop.
Content-Type: text/event-stream that pushes small text records — data, event, id, retry, and heartbeat comments — one way, from server to client, consumed by the built-in EventSource API. Its superpower is automatic reconnect with Last-Event-ID resume: the reliability that a raw WebSocket makes you build by hand, SSE gives you for free, as the lab makes visible. It is text-only, one-way, and holds a connection per stream — so serve it over HTTP/2 to escape the six-connection limit, disable proxy buffering, flush after each event, and heartbeat past idle timeouts. Choose SSE for notifications, dashboards, progress, log tails, and LLM token streaming; choose WebSocket only when you genuinely need bidirectional, high-frequency, or binary traffic. For the common case where the server pushes and the browser listens, SSE is the simplest tool that is usually enough.