Real-TimeGENERALNETWORK-SPECIFICPLATFORM-SPECIFIC

Server-Sent Events

One-way, text, and reconnecting by default: what EventSource does for you, how Last-Event-ID closes a gap, and the per-origin connection limit that only bites in the fifth tab.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

When is a one-way text stream with reconnection already built in the right answer, and what is the trap that only appears in production?

The user intent

A person starts a long export and wants to watch it progress, or leaves a notifications panel open and wants new items to appear. They are receiving, not sending.

The obvious build

This needs pushing, so it needs a WebSocket. SSE is the old one-way thing; we may as well use the general-purpose transport since we will want two-way eventually.

Why it breaks

"Eventually two-way" almost never arrives, and in the meantime you have written reconnection, backoff and heartbeat code that EventSource would have provided for free (Reconnect and Backoff).

How it breaks in a real browser
  • "Eventually two-way" almost never arrives, and in the meantime you have written reconnection, backoff and heartbeat code that EventSource would have provided for free (Reconnect and Backoff).
  • The socket loses every HTTP affordance in the path — the gateway that authenticates requests, the log that records them, the proxy that compresses them — for a feature whose payload was a line of text.
  • Going the other way and reaching for SSE without reading the connection rules produces the classic bug: everything works, until a user with several tabs open finds that ordinary requests to the same origin hang forever.
  • Someone tries to attach an Authorization header to an EventSource and discovers the constructor takes a URL and one option. The auth design has to be made before the transport is chosen, not after (Cookies vs Script-Readable Tokens).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • An EventSource issues an ordinary GET with Accept: text/event-stream. The server responds with that content type and simply does not end the body (Server-Sent Events in API Design covers the contract side).
  • The body is a line-based text format. Blank-line-separated blocks of data:, optional event:, optional id:, optional retry:. The browser parses it — you never see the framing.
  • A block with no event: field dispatches as a message event. A block with event: order.created dispatches to addEventListener('order.created', ...), which is genuine multiplexing without a discriminator field in your payload.
  • When the connection drops, the browser reconnects on its own after a delay the server can set with a retry: field. Your code does nothing; the onerror handler fires and the browser is already retrying (Reconnect and Backoff).
  • On reconnect the browser sends the last id: it saw as a Last-Event-ID request header. If the server honours it by replaying from that position, the gap closes without a single line of client code (Resynchronisation After a Gap).
  • The stream is UTF-8 text only. Binary payloads must be encoded, which is a real cost for anything image- or buffer-shaped (Structured Clone and Transferables explains why base64 in a text channel is rarely free).

What this makes the browser do

And which of it is avoidable.

  • Holding the response open occupies a connection to the origin for the life of the page. On HTTP/1.1 the browser will only open a small number of connections per origin — commonly six — and an open stream consumes one of them permanently (Reading a Network Waterfall).
  • Parsing is incremental and cheap, but dispatch is not: each event is a task, and a handler that commits state per event drives one render per message (Tasks: The Unit That Cannot Be Interrupted).
  • The browser retains the last event id per EventSource instance and re-sends it automatically. That bookkeeping is free; deciding what to do with the replay is not.
  • Avoidable work: opening a stream per component instead of one per tab, and leaving it open in a hidden tab where nobody can see the results (Long-Lived Clients and Version Skew).

What `EventSource` already does

The client is genuinely this small, and the smallness is the argument. There is no reconnect loop, no backoff, no heartbeat, no id bookkeeping — the browser owns all four, and it owns them consistently across engines.

The two things you do own are visible in the code: deciding what a message means to your state, and deciding when the stream should exist at all. Both are application decisions and neither is transport work.

  • EventSource.CONNECTING after an error means the browser will retry by itself; treating that as a fatal error and building a second retry loop on top produces two competing reconnection policies.
  • The constructor takes a URL and { withCredentials }. There is no header option, and that constraint should shape the auth design rather than be discovered by it.
  • Every event carries lastEventId, so your reducer always knows its position in the stream without tracking it separately (Ordering and Duplicate Delivery).
One stream per tab, subscribed to by features
1// One instance, owned by one module. Not one per component.
2const es = new EventSource('/api/stream') // cookies ride along, same-origin
3
4// Named events demultiplex without a discriminator field in the payload.
5es.addEventListener('order.created', (e) => {
6 applyEvent(JSON.parse(e.data), e.lastEventId)
7})
8es.addEventListener('order.updated', (e) => {
9 applyEvent(JSON.parse(e.data), e.lastEventId)
10})
11
12// onerror does NOT mean "give up". The browser is already reconnecting;
13// readyState tells you which state it is in.
14es.onerror = () => {
15 setConnectionState(es.readyState === EventSource.CLOSED ? 'closed' : 'reconnecting')
16}
17es.onopen = () => setConnectionState('live')
18
19// The stream is a resource with an owner and a lifetime.
20window.addEventListener('pagehide', () => es.close())

What is absent is the lesson: no timer, no attempt counter, no jitter, no Last-Event-ID tracking. es.readyState distinguishes "reconnecting" from "permanently closed", which is the distinction the UI needs to render honestly.

The wire format, and the header that closes the gap

GENERALThe four field names (data, event, id, retry) and the comment form are specified in HTML's event stream parsing rules and are identical in every browser; what varies is whether the *server* implements Last-Event-ID replay at all, which is a backend contract decision rather than a browser one.

The format is deliberately trivial: UTF-8 lines, blank-line-separated blocks, four defined field names. Being able to read it with curl is a genuine operational advantage over a framed binary protocol, and it means the stream can be debugged with the same tools as everything else HTTP.

id: is the field that matters most and is skipped most often. It is what the browser echoes back as Last-Event-ID on reconnection, and it is therefore the only mechanism by which a gap can be closed without extra code. A server that emits data without ids has built a transport that silently loses messages on every disconnect.

Response, then the reconnect that resumes
1HTTP/1.1 200 OK
2Content-Type: text/event-stream
3Cache-Control: no-store
4Connection: keep-alive
5
6retry: 3000
7
8id: 1041
9event: order.created
10data: {"id":"o_88","total":1299}
11
12id: 1042
13event: order.updated
14data: {"id":"o_88","status":"paid"}
15
16: keep-alive comment, defeats idle timeouts, dispatches nothing
17
18--- connection drops here; the browser waits, then re-requests ---
19
20GET /api/stream HTTP/1.1
21Accept: text/event-stream
22Last-Event-ID: 1042
23Cookie: session=...

retry: sets the browser's reconnection delay in milliseconds — the one place a real millisecond number belongs, because it is a protocol field rather than a claim about performance. A line beginning with : is a comment that keeps intermediaries from declaring the connection idle.

The limit that only bites in the fifth tab

This is the trap. On HTTP/1.1 a browser will hold only a small number of simultaneous connections to a single origin, and an open stream occupies one of them for as long as the page lives. One tab is fine. Several tabs of the same application, each holding a stream, exhaust the budget — and the symptom is not "the stream broke" but "every other request to this origin hangs", which sends the investigation in entirely the wrong direction.

The fix is usually protocol rather than code: over HTTP/2 and HTTP/3 the stream is one multiplexed stream on a shared connection and the constraint largely dissolves. Where that is not available, the answers are one stream per tab, closing on hide, or sharing a single stream across tabs via a shared worker (When a Worker Is Actually the Answer).

How an SSE feature fails in production and not in review
TriggerSymptomCauseResponse
User opens the app in several tabs over HTTP/1.1Unrelated requests to the same origin queue forever; the app appears frozenEach tab holds a stream, consuming the small per-origin connection budgetServe over HTTP/2 or HTTP/3; otherwise one stream per tab, closed on hide, or shared through a shared worker (Web Workers and the DOM Boundary).
A gateway or proxy buffers response bodiesThe connection is open and no events ever arriveThe intermediary waits for a complete response before forwarding anythingDisable buffering for the stream route; confirm from the customer network, not from a laptop (Forward and Reverse Proxies in Networking).
Server closes idle streams on a timeout, with no retry: hintA reconnect every few seconds and a request rate nobody plannedThe default reconnection delay is short, and the server has not asked for a longer oneEmit retry: and periodic comment lines so the connection is never idle long enough to be reaped (Timeouts in Backend Engineering).
Server ignores Last-Event-IDEvents that happened during a disconnect never appear; state quietly divergesReconnection is a browser feature; resumption is a server feature, and only one of them was implementedReplay from the id, or return a fresh snapshot on connect and let the client rebuild (Resynchronisation After a Gap).
Replay after a long disconnectA burst of hundreds of events, a long task, and a frozen page at the worst momentOne state commit and one render per eventBatch the backlog into a single commit before rendering (Yielding and Scheduling).
Token passed in the stream URLCredential visible in access logs and referrersEventSource cannot send custom headers, so the token was put where it could goPrefer same-origin cookies; if a URL token is unavoidable, make it single-use and short-lived (Short-Lived Credentials in Security).

How to build it

Most important first.

  • Emit id: on every event, always. It costs the server nothing and it is the entire resumption story; without it, a reconnect silently starts from "now" and the gap is invisible (Ordering and Duplicate Delivery).
  • Use named events for distinct kinds of message rather than one message type with a kind field. The browser demultiplexes for you and the handlers stay small.
  • Open exactly one stream per tab, in one module, and let features subscribe to it. Several EventSource instances to the same origin is how the connection limit gets hit (Who Owns This State?).
  • Close it when the tab is hidden for a long time and reopen on visibility, if the feature tolerates it. This is both a resource decision and an accessibility one — nothing needs announcing to somebody who is not there.
  • Decide the auth story explicitly: same-origin cookies are the simple path, since EventSource sends them and cannot send custom headers. A short-lived token in the URL leaks into access logs and referrers, so treat it as a last resort with a short lifetime (What the Frontend Is Responsible For in Auth).
  • Serve the stream over HTTP/2 or HTTP/3 where you can. Multiplexing removes the per-origin connection problem almost entirely and changes nothing else about the code (HTTP/1.1 vs HTTP/2 vs HTTP/3 in Networking).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • A stream that delivers items into a live region will announce every one of them. For a notification feed that is a screen reader reading a scrolling list aloud continuously, which is worse than silence because it cannot be interrupted meaningfully (Live Regions and Announcement).
  • Announce a batched summary on a cadence rather than each event: a polite status region that changes to "4 new items" is usable; four separate announcements are not.
  • For streamed progress — an export, a model response — announce start and completion, and leave the intermediate stream unannounced or announce it on a slow interval. Continuous text arrival is the hardest case in this whole area (Streaming a Response Without Melting the Device).
  • Never prepend new items above the user's reading or focus position without warning. Appending below, or buffering behind a "show new items" control the user activates, preserves their place (Focus Management).

What can go wrong

Failure modes
  • The per-origin connection limit on HTTP/1.1: several tabs each hold a stream, the budget is exhausted, and every other request to that origin queues indefinitely. It presents as "the app hangs after a while", which points investigation at everything except the stream.
  • A buffering proxy or gateway that will not flush a partial response. Locally perfect, silent through the customer's network appliance.
  • A server that closes the stream on a timeout without a retry: hint, producing a reconnect every few seconds and a request rate no one intended (Retry Storms: The Load You Generated Yourself in Observability).
  • Honouring Last-Event-ID only for a short retention window, then silently starting from "now" when the client asks for something older. The client believes it resumed; it did not (Resynchronisation After a Gap).
  • The mitigation failing: emitting a keep-alive comment line to defeat idle timeouts, and setting the interval longer than the shortest intermediary timeout in the path, so it defeats nothing.
What can arrive out of order
  • The initial snapshot fetch and the first streamed events race. If the snapshot is requested first but resolves after event 41 has already been applied, applying the snapshot naively rolls the UI backwards (Out-of-Order Responses).
  • A reconnect with Last-Event-ID can deliver events the client already applied, because the server replays from the last id it was *told* about rather than the last one you processed. Duplicates are the normal case, not an anomaly (Ordering and Duplicate Delivery).
  • Closing and reopening on visibility change races with in-flight events: the close can land after the server has queued events for a connection that is going away.
Security
  • A same-origin EventSource sends cookies automatically, so the stream is authenticated exactly like any other request to that origin — and, like any other request, that means the server must re-check authorization rather than trusting the connection (Authorization-Aware UI).
  • Cross-origin streams follow the same response-access rules as fetch: the origin serving the stream must opt in, and credentials require withCredentials plus explicit server agreement (CORS).
  • connect-src in a Content-Security-Policy constrains which origins an EventSource may connect to at all, which limits where an injected script can exfiltrate to (Content Security Policy).
  • Every data: payload is attacker-influenced input arriving directly into your application. Escaping and sanitisation apply identically to pushed content and fetched content (Sanitization and Trusted HTML).
  • A token in the stream URL ends up in server access logs, in proxy logs and potentially in a Referer. If a token must travel that way, make its lifetime short enough that a log leak is not a session takeover (Session Hijacking).
Misreads
  • "SSE is deprecated / legacy." It is a current part of the HTML standard, well supported, and the default choice for one-way text — including for streamed model output, which is the most visible real-time UI being built right now.
  • "EventSource reconnects, so I do not need to think about disconnection." It reconnects; it does not tell you what you missed. Resumption is the server honouring Last-Event-ID (Resynchronisation After a Gap).
  • "The connection limit is a specification rule." It is a browser implementation behaviour on HTTP/1.1, it differs between browsers, and it mostly disappears on HTTP/2 — which is why the advice depends on your protocol version, not on your framework.
  • "I can send an auth header." You cannot. EventSource takes a URL and a withCredentials flag; the auth design has to fit that shape.
  • "One stream per component keeps things modular." It keeps things modular right up to the point where six components on one page exhaust the origin's connection budget.

Measuring it, and what changes in the field

How you would see this
  • The Network panel shows the request with an eventsource type and a dedicated messages view listing each event with its type, id and data — the closest thing to a debugger this transport has (Debugging the Network).
  • A request that reappears in the list every few seconds is a reconnect loop, not a stream. Count reconnects per session in the field, because locally there are none (Real User Monitoring).
  • The number of simultaneously open connections to your origin, per user, across tabs. This is the metric that predicts the connection-limit failure before a customer reports it.
  • Main-thread time in event handlers when the stream bursts; a replayed backlog after a reconnect is the worst case and is exactly when the user is watching (Long Tasks).
Slow device, slow network, large data, old tab
  • On HTTP/1.1 the per-origin connection budget makes multi-tab use the limiting factor; on HTTP/2 and HTTP/3 the stream is one multiplexed stream among many and the practical ceiling is far higher, though still finite and set by the server.
  • On a mobile network the connection drops routinely, so Last-Event-ID support on the server is not a nicety — it is the difference between resumption and a silent gap on every tunnel and lift (Resynchronisation After a Gap).
  • In a background tab the stream stays open and events keep arriving, doing work nobody can see. On a low-memory device the tab may be discarded entirely and the stream never resumes (The Multi-Process Browser).
  • With a large replay backlog, reconnection delivers hundreds of events in one burst, which is a rendering problem rather than a networking one (List Virtualization).
What this costs
  • You get reconnection and resumption for free and give up the client-to-server direction entirely: anything the client sends is a separate HTTP request, which is usually fine and occasionally awkward.
  • Text-only means binary payloads must be encoded, paying roughly a third in size plus encode and decode cost on both ends.
  • One connection per tab is a real resource. The cheapest mitigation — closing on hide and reopening on show — introduces gaps that must then be resynchronised.
  • Server-side, a held-open response per client constrains your process model; that constraint belongs to the backend but it is created by this frontend choice (Writing Event Consumers in Backend Engineering).

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe EventSource API, the text/event-stream framing, automatic reconnection and the Last-Event-ID header are all specified in HTML and behave the same across Blink, Gecko and WebKit; what differs is devtools presentation and connection accounting.
  • NETWORK-SPECIFICThe per-origin connection cap applies to HTTP/1.1, where a held-open stream consumes one of a small budget (commonly six in current browsers); over HTTP/2 and HTTP/3 the stream is multiplexed and the effective limit is the server's max concurrent streams, which is typically an order of magnitude higher.
  • PLATFORM-SPECIFICIntermediaries decide whether a stream works at all: a proxy or gateway that buffers response bodies will deliver nothing until the response ends, which no browser-side change can fix and which differs per customer network rather than per browser.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Performancetail-latency
Domains that do not exist yet
  • Distributed Systems — how long a server can retain an event log so that Last-Event-ID replay is possible, and what happens to a client that asks to resume from a position that has been compacted away.