Core Concepts
Server-Sent Events
Open a connection once. The server pushes updates whenever they happen. No repeated requests.
What Are Server-Sent Events?
A long-lived HTTP response that carries text-framed events from server to browser
Server-Sent Events (SSE) let a server send a sequence of text-framed events over a long-lived HTTP response. The browser's EventSource API receives each message as it arrives and normally reconnects on its own after an interruption. SSE is one-way at the application stream level: the server pushes events to the client, while anything the client wants to say goes over an ordinary HTTP request or some other channel.
A build dashboard is the natural example. A user starts a build with POST /builds, then opens GET /builds/42/events and receives queued, running, test_passed and completed events without polling. The browser renders progress as the server writes each event to the response. Everything is plain HTTP: the same URL space, cookies, TLS, load balancers and access logs as the rest of the application.
The mental model is a response that never quite finishes. An ordinary JSON request returns a complete body and the connection goes back to being idle. An SSE response sends its headers, then keeps writing small framed messages onto the same body for minutes or hours. The client is reading a file that is still being written. That is why the protocol itself is trivial and almost every real problem lives in what sits between the two ends: proxies that buffer or time out, compression layers, connection limits, and the question of what the server remembers after the connection is gone.
Compare this with the neighbouring techniques. Polling asks again on a timer. Long polling parks one request until something happens, answers, and the client must immediately ask again. SSE answers once and keeps answering. In the figure, messages 1 and 2 are an ordinary request and response; message 3 is the GET whose response, from message 4 onward, is a single response whose body keeps growing. Because a streaming response behaves differently from a short JSON request, intermediaries that treat responses as finite documents (buffering them, idling them out, compressing them) can delay or terminate the stream unless they are configured for it. The rest of the lesson follows that chain: frames on the wire, the client's reconnect rules, replay, proxies, limits, slow clients and operations.
Visual Story
One connection → events pushed as they happen → drop → reconnect with Last-Event-ID
Server-Sent Events
⤢ Expand for synchronized narration timeline
Understanding the Wire Format
Field lines, blank-line dispatch, and why raw JSON is not SSE
The response uses Content-Type: text/event-stream and UTF-8 text. Each event is made of field lines and is terminated by a blank line:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
id: build-42:17
event: progress
data: {"build_id":"42","step":"tests","percent":65}
id: build-42:18
event: completed
data: {"build_id":"42","result":"success"}The first lines are the ordinary HTTP response head. The body is the event stream; nothing in it says how long the stream will last.
The grammar is small. A line is field: value, and one space after the colon is dropped. Lines end with LF, CRLF or a lone CR. The recognised fields are:
data:carries the payload. It may appear several times; the parser joins consecutive data lines with newline separators and strips the final newline.event:names the event type. Without it the event is of typemessage, which is whatsource.onmessagereceives; named events needaddEventListener.id:sets the last event ID that the browser sends back in theLast-Event-IDrequest header when it reconnects. An ID containing a null character is ignored.retry:with an integer suggests the reconnect delay in milliseconds. Anything that is not all digits is ignored.- A line starting with
:is a comment. The parser ignores it, which makes it the standard heartbeat. Unknown field names are ignored too.
A blank line dispatches the event. Until it arrives, the fields are only buffered. This is the most common source of "my events never show up" reports: SSE is not raw JSON streamed without framing, and a missing \n\n means the browser is waiting politely for the end of an event that has already been sent. Two further details matter in practice. A blank line with no data: buffered dispatches nothing, and if the connection ends mid-event the buffered fields are discarded rather than delivered half-finished.
Two data lines. The parser appends each value followed by a newline and removes the final newline at dispatch, so the listener sees the two lines joined by a single newline. The blank line is what triggers dispatch. The ID is committed on dispatch.
The third example is the one to internalise. Event 20 was fully written by the server but never terminated. The client does not see it, its last committed ID stays at 19, and after reconnecting it receives 20 again. So the delivery guarantee you can build on SSE is at least once, and handlers have to tolerate the same event twice. The Idempotency lesson covers the general technique; here the cheap version is to remember the highest sequence number you applied and ignore anything at or below it.
A server you can run
The following is a complete, dependency-free Node server for the build example. It writes the three events one second apart, includes id: fields, and, because it reads the Last-Event-ID header, picks up where a reconnecting client left off. Three response details do real work. no-cache stops intermediaries from storing the stream, no-transform asks them not to re-encode it, and X-Accel-Buffering: no is honoured by nginx to disable buffering for this response. Writing the first bytes immediately sends the response head, so the browser fires open without waiting for the first real event.
// server.mjs: node server.mjs (Node 18+, no dependencies)
import http from 'node:http';
function frame({ id, event, data, retry }) {
let out = '';
if (retry !== undefined) out += `retry: ${retry}\n`;
if (id !== undefined) out += `id: ${id}\n`;
if (event) out += `event: ${event}\n`;
const text = typeof data === 'string' ? data : JSON.stringify(data);
for (const line of text.split(/\r\n|\r|\n/)) out += `data: ${line}\n`; // one data: line per line of text
return out + '\n'; // the blank line dispatches the event
}
const steps = [
['status', { state: 'running' }],
['progress', { step: 'tests', percent: 65 }],
['completed', { result: 'success' }],
];
http.createServer((req, res) => {
if (req.url !== '/builds/42/events') return res.writeHead(404).end();
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no', // nginx: do not buffer this response
});
res.write('retry: 5000\n\n'); // headers + first bytes leave immediately; no data, so nothing dispatches
// Resume after the last event the client reported. Event ids are "build-42:<n>".
let sent = Number(/^build-42:(\d+)$/.exec(req.headers['last-event-id'] ?? '')?.[1] ?? 0);
const timer = setInterval(() => {
if (sent >= steps.length) { clearInterval(timer); return res.end(); }
const [event, data] = steps[sent++];
res.write(frame({ id: `build-42:${sent}`, event, data }));
}, 1000);
req.on('close', () => clearInterval(timer)); // client went away: stop producing
}).listen(3000);Two framing hazards deserve a line of defence in any real implementation. A payload containing newlines must be split into one data: line per line of text (the helper does this), otherwise the second line is parsed as a field name and silently dropped. And never put user-controlled text into the event: or id: value without removing newlines, because a newline there lets the sender forge extra fields. JSON from JSON.stringify contains no raw newlines, which is one reason JSON payloads are the usual choice.
How EventSource Connects, Fails and Retries
Three states, one retry rule, and the cases where the browser gives up
The browser side is a few lines of JavaScript:
const source = new EventSource('/builds/42/events', { withCredentials: true });
source.addEventListener('progress', (event) => {
const update = JSON.parse(event.data);
renderBuildProgress(update);
});
source.addEventListener('completed', (event) => {
const result = JSON.parse(event.data);
renderFinalResult(result);
source.close(); // otherwise the server ending the response triggers a reconnect
});
source.onerror = () => {
// The browser may reconnect automatically; show degraded connectivity.
if (source.readyState === EventSource.CLOSED) {
// It will NOT reconnect (non-200, wrong Content-Type, 204). Create a new EventSource yourself.
}
};The calls hide a state machine that decides which failures heal themselves. A new EventSource starts in CONNECTING. A 200 response with text/event-stream moves it to OPEN and fires open. If an open connection drops, or the network fails, it fires error, returns to CONNECTING, waits the retry delay, and requests again. That is the automatic reconnection people mean.
What the figure makes explicit is the exit to CLOSED. Per the HTML standard, a response with any status other than 200, a different Content-Type, or an HTTP 204 fails the connection and the browser does not retry. That has two consequences. A 204 is a deliberate way for the server to say "stop reconnecting", useful for a stream that has genuinely ended. More dangerously, a 502 or 503 from a proxy in the middle of a deploy kills the client for good, and the only signal is an error event with readyState === EventSource.CLOSED. Production code needs a handler that recreates the object with its own backoff, as the resuming client later in this lesson does.
Two more behaviours surprise people. When the server ends the response normally (it calls res.end()), the browser treats that as a dropped connection and reconnects, so a client that has seen the final event should call close() itself, as the example does. And the default delay before reconnecting is browser-defined, typically a few seconds, with no exponential backoff and no jitter. The server can change it with retry:, and should, for reasons that return in the scaling section.
Authentication, headers and CORS
Native EventSource does not expose an arbitrary request-header API. It issues a GET with no body, so an application that relies on bearer tokens in an Authorization header cannot use it directly. The realistic options each carry a cost:
- Cookie-based authentication. Same-origin it just works. Cross-origin you need
new EventSource(url, { withCredentials: true })on the client, and on the serverAccess-Control-Allow-Credentials: truewith an explicit origin inAccess-Control-Allow-Origin, since a wildcard is rejected for credentialed requests. - A short-lived stream token in the query string. Mint it from an authenticated endpoint, bind it to one user and one stream, and expire it in well under a minute. Query strings reach access logs and browser history, so they must be redacted or kept out of logs. Note the interaction with reconnection: the browser reconnects to the same URL, so a strictly single-use token breaks the first retry, while a reusable one is a leaked-URL risk. The window has to be chosen deliberately.
- A fetch-based streaming client.
fetchaccepts headers and exposes the response body as a stream, so it can carry the Authorization header and aLast-Event-IDyou control. You give up the built-in parser and reconnection and must reimplement both.
// Fetch-based SSE client: allows an Authorization header, but reconnection is now your job.
// Assumes the server terminates lines with \n (as the frame() helper above does).
function parseFrame(raw) {
const ev = { type: 'message' };
const data = [];
for (const line of raw.split('\n')) {
if (line === '' || line.startsWith(':')) continue; // blank or comment
const c = line.indexOf(':');
const field = c === -1 ? line : line.slice(0, c);
let value = c === -1 ? '' : line.slice(c + 1);
if (value.startsWith(' ')) value = value.slice(1); // one leading space is dropped
if (field === 'data') data.push(value);
else if (field === 'event') ev.type = value;
else if (field === 'id' && !value.includes('\0')) ev.id = value;
else if (field === 'retry' && /^\d+$/.test(value)) ev.retry = Number(value);
}
if (data.length) ev.data = data.join('\n');
return ev;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function streamEvents(url, getToken, onEvent, signal) {
let lastId = '';
let retryMs = 3000;
let failures = 0;
while (!signal.aborted) {
try {
const res = await fetch(url, {
signal,
headers: {
Accept: 'text/event-stream',
Authorization: `Bearer ${await getToken()}`,
...(lastId && { 'Last-Event-ID': lastId }),
},
});
if (res.status === 204 || res.status === 401 || res.status === 403) return; // server says: stop
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
failures = 0;
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buf = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += value;
let end;
while ((end = buf.indexOf('\n\n')) !== -1) {
const ev = parseFrame(buf.slice(0, end));
buf = buf.slice(end + 2);
if (ev.retry) retryMs = ev.retry;
if (ev.id !== undefined) lastId = ev.id;
if (ev.data !== undefined) onEvent(ev);
}
}
} catch (err) {
if (signal.aborted) return;
}
// Exponential backoff with jitter; EventSource would simply wait retryMs every time.
const delay = Math.min(30000, retryMs * 2 ** Math.min(failures++, 5));
await sleep(delay / 2 + Math.random() * (delay / 2));
}
}Simplified: it assumes LF line endings and does not treat a stream that ends mid-event specially, matching what the native parser would discard. Libraries exist for this; the point is how little the protocol requires.
Whatever the mechanism, authentication is checked when the stream opens, and a stream can outlive the credential that authorised it. Decide what happens at token expiry: close the stream, let the client re-authenticate, and reconnect. Respond 401 or 403 to an unauthorised reconnect so the client lands in CLOSED and refreshes, rather than hammering the endpoint.
How Reconnection and Last-Event-ID Work
Reconnect is automatic; replay only exists if the backend builds it
After an interruption, the browser reconnects and, if it has received an event with an ID, sends it in a Last-Event-ID request header. That helps the server resume after a known point only if the backend can replay events. The protocol itself stores no history and promises no recovery: it hands the server a cursor and nothing else. The header is sent on automatic reconnects of an existing EventSource; a page reload creates a new object with no last ID, so a client that wants to resume across reloads has to persist its cursor and pass it explicitly, for instance as ?after=17.
Warning. The header only says where the client stopped. The replay in messages 9 to 11 exists because the server kept the events and chose to look them up.
For a durable event stream, the server should validate the cursor, query events after it, emit the missed ones, then follow live updates. The first variant in the figure is that path; the second is what happens with a transient in-memory publisher, which cannot reconstruct events missed during downtime. Switch between them: the browser sends the same header in both, and only the backend decides whether it means anything.
Cursor design
A cursor is only useful if comparing it with an event is unambiguous. IDs such as build-42:17 scope the sequence to one stream and let the server reject a malformed or foreign cursor. The sequence must be ordered by commit, ideally gapless. A global auto-increment column does not give you that: two transactions can take values 19 and 20, commit in the opposite order, and a reader that has seen 20 and stored it as its cursor will never ask for 19. A per-stream counter assigned under the primary key avoids it, and as a bonus lets the client detect holes by checking that each ID is exactly one more than the last.
-- PostgreSQL. Per-stream, gapless, commit-ordered sequence numbers.
CREATE TABLE build_event (
build_id text NOT NULL,
seq integer NOT NULL,
type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (build_id, seq)
);
-- Append. The primary key rejects a concurrent writer that picked the same seq; that writer retries.
INSERT INTO build_event (build_id, seq, type, payload)
VALUES ('42',
(SELECT COALESCE(MAX(seq), 0) + 1 FROM build_event WHERE build_id = '42'),
'progress', '{"step":"tests","percent":65}');
-- Replay everything after the client's cursor (17), bounded so one reconnect cannot read the world.
SELECT seq, type, payload
FROM build_event
WHERE build_id = '42' AND seq > 17
ORDER BY seq
LIMIT 1000;
-- Retention, and the oldest cursor that can still be served without a gap.
DELETE FROM build_event WHERE created_at < now() - interval '1 day';
SELECT MIN(seq) AS oldest FROM build_event WHERE build_id = '42';Runnable as written. For many writers per stream, serialise appends or use a per-stream counter row; the primary key guarantees no duplicate seq either way.
Replay without a gap
Replaying history and then going live looks like two simple steps, and the order of them is a correctness issue. This is the same check-then-wait race that bites long polling: if the handler reads the store first and subscribes to live events afterwards, any event committed between the two disappears.
The sound order is to subscribe first and buffer, read history, flush history, drain the buffer, and then write live events directly. Events can now appear twice, once from the store and once from the buffer, so the handler skips any event whose sequence number is not greater than the last one it wrote. Duplicates are handled by a comparison; gaps cannot occur. The handler below also covers the other recovery case: if the cursor is older than what the store retains, it does not guess and sends an explicit resync event telling the client to fetch a snapshot.
// Express 4+. authenticate, canView, store and broker are your application's modules:
// store.oldestSeq(buildId) -> number | null store.after(buildId, seq) -> [{ seq, type, payload }]
// broker.subscribe(topic, fn) -> unsubscribe (fn receives { seq, type, payload })
// frame() is the helper from the minimal server above.
function readCursor(req, buildId) {
const header = req.get('Last-Event-ID'); // set by the browser on automatic reconnects
if (header) {
const m = /^build-(.+):(\d+)$/.exec(header);
return m && m[1] === buildId ? Number(m[2]) : null; // null = malformed or from another stream
}
const after = req.query.after; // the first connect after a page load
if (after === undefined) return 0;
return /^\d+$/.test(String(after)) ? Number(after) : null;
}
app.get('/builds/:id/events', async (req, res) => {
const buildId = req.params.id;
const user = await authenticate(req); // cookie session, validated at connect time
if (!user) return res.sendStatus(401);
if (!(await canView(user, buildId))) return res.sendStatus(403);
const cursor = readCursor(req, buildId);
if (cursor === null) return res.sendStatus(400);
res.set({
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
let lastSent = cursor;
let replaying = true;
const pending = [];
const send = (e) => {
if (e.seq <= lastSent) return; // already delivered: drop the duplicate
lastSent = e.seq;
res.write(frame({ id: `build-${buildId}:${e.seq}`, event: e.type, data: e.payload }));
};
// 1. Subscribe BEFORE reading history, and hold live events until the replay is done.
const unsubscribe = broker.subscribe(`build:${buildId}`, (e) => (replaying ? pending.push(e) : send(e)));
const heartbeat = setInterval(() => res.write(': keepalive\n\n'), 15000);
req.on('close', () => { unsubscribe(); clearInterval(heartbeat); });
// 2. Cursor older than retention: tell the client to resync from a snapshot instead of guessing.
const oldest = await store.oldestSeq(buildId);
if (oldest !== null && cursor + 1 < oldest) {
res.write(frame({ event: 'resync', data: { reason: 'cursor_expired', snapshot: `/builds/${buildId}` } }));
return res.end(); // the client closes its EventSource on 'resync'
}
// 3. Replay history, then switch to live. The flag flip and the drain run synchronously after
// the last await, so no live event can slip between them.
for (let batch = await store.after(buildId, lastSent); batch.length; batch = await store.after(buildId, lastSent)) {
batch.forEach(send);
}
replaying = false;
pending.forEach(send); // send() skips anything the replay already covered
});If the process that publishes to the broker dies between committing an event and publishing it, live subscribers never see that event. A sequence-checking client notices on the next event because the number jumped, and reopens from its last good cursor, which replays the hole from the store. Writing the event and its publication atomically is the job of the outbox pattern; the gap check is the cheap second line of defence.
function watchBuild(buildId, apply) {
let lastSeq = 0;
let source = null;
let timer = null;
const close = () => { source?.close(); clearTimeout(timer); };
const reopen = (delayMs = 0) => {
close();
timer = setTimeout(open, delayMs);
};
function open() {
// The query parameter only matters for this first request; the browser's own
// Last-Event-ID header takes over on automatic reconnects (the server prefers the header).
source = new EventSource(`/builds/${buildId}/events?after=${lastSeq}`, { withCredentials: true });
const onFrame = (event) => {
const seq = Number(event.lastEventId.split(':')[1]);
if (seq <= lastSeq) return; // replayed duplicate
if (seq !== lastSeq + 1) return reopen(); // hole in the sequence: ask again from lastSeq
lastSeq = seq;
apply(event.type, JSON.parse(event.data));
if (event.type === 'completed') close();
};
['status', 'progress', 'completed'].forEach((t) => source.addEventListener(t, onFrame));
source.addEventListener('resync', async () => { // cursor fell out of server retention
close();
const snapshot = await (await fetch(`/builds/${buildId}`)).json();
lastSeq = snapshot.lastSeq;
apply('snapshot', snapshot);
open();
});
source.onerror = () => {
// CONNECTING: the browser is retrying by itself. CLOSED: it gave up, so we start over.
if (source.readyState === EventSource.CLOSED) reopen(1000 + Math.random() * 4000);
};
}
open();
return close;
}How long a cursor stays valid
Retention turns directly into a recovery guarantee. A stream emitting 5 events per second with 1,000 events kept can resume any client that was gone for about 200 seconds, including the reconnect delay. A ten-minute outage misses roughly 3,000 events (603 seconds at 5 per second is 3,015), so that client must resync from a snapshot. The calculator makes the trade explicit: more retention costs memory or storage and lengthens the replay burst a reconnecting client receives, and the burst arrives on top of live traffic.
- Missed events
- 115
- 23 s away at 5/s
- Replay window
- 200 s
- events kept ÷ rate
- Outcome
- Replay from cursor
- Buffer memory
- 0.5 MB
- at 500 B per event
Whether the fallback is "resync" or "resume", the product has to define it. For a progress bar a snapshot of the current state is enough. For an audit feed, the snapshot must be paired with the missed history, or the stream should be sourced from a log that is retained for as long as clients might be away.
Heartbeats and Proxy Buffering
Why a healthy 200 does not mean events are arriving, and how to measure it
Some intermediaries close connections that look idle. Sending a comment heartbeat such as : keepalive followed by a blank line at a suitable interval keeps data flowing, subject to what the infrastructure actually does. The comment is invisible to your handlers, costs 13 bytes, and resets idle timers that count bytes on the wire. Pick the interval from the shortest timeout on the path (a load balancer, a proxy's read timeout, a mobile carrier gateway), not from a default. Common defaults are around a minute, which is why 15 to 30 seconds is a typical interval, but check the real values for your environment. A second use is liveness: a write to a connection the client has abandoned eventually fails, and that is when the server learns it can clean up.
Survives with margin for one lost heartbeat.
- Heartbeats per second
- 6,667
- streams ÷ interval
- Payload rate
- 86.7 KB/s
- 13 B each, headers excluded
- Per stream per day
- 74.9 KB
- Gap ÷ timeout
- 25 %
- keep well under 100 %
The arithmetic is modest per connection and visible in aggregate. At the default 100,000 streams and a 15-second interval the fleet emits about 6,700 heartbeats per second, roughly 87 KB/s of payload and about 75 KB per stream per day, before TCP and TLS overhead, which usually dominates for a message this small. Aim for a gap of half the timeout or less so one delayed heartbeat does not cut the connection.
Buffering is the usual reason "streaming" does not stream
The server should flush the response when it sends events. If a proxy buffers the stream, events arrive in batches seconds after the application wrote them, and the status code, headers and server logs all look fine. The page appears to be polling slowly, which is the symptom to recognise.
- Average delay
- 0.05 s
- Worst delay
- 0.05 s
- HTTP status seen
- 200
- looks healthy in every mode
Check each layer: reverse-proxy buffering settings, compression middleware, CDN support for streaming responses, idle timeouts, and gateway streaming behaviour. For nginx, disabling response buffering for the SSE location is a common requirement, and the response header X-Accel-Buffering: no does it per response. The configuration below is a starting point that should be reviewed against your version and topology.
upstream sse_backend { server 127.0.0.1:3000; }
server {
listen 80;
location /builds/ {
proxy_pass http://sse_backend;
proxy_http_version 1.1;
proxy_set_header Connection ''; # keep the upstream connection reusable, no "close"
proxy_buffering off; # pass bytes through as they arrive
proxy_cache off;
gzip off; # or make sure the compressor flushes after every write
proxy_read_timeout 1h; # time allowed between two reads from the upstream; default is 60 s
}
}Compression is the quieter offender. A gzip or brotli encoder holds bytes until a block fills, so an event stream compressed without flushing arrives all at once when the response ends. Either exclude text/event-stream from compression or make sure the compressor flushes after each event. At event sizes of tens of bytes the saving is small anyway.
// Express with the "compression" middleware: skip event streams, or call res.flush() after each write.
import compression from 'compression';
app.use(compression({
filter: (req, res) =>
String(res.getHeader('Content-Type') ?? '').startsWith('text/event-stream')
? false
: compression.filter(req, res),
}));Do not assume that a successful 200 proves events are being delivered promptly. Measure the time from event creation to browser receipt. A reliable way is a synthetic client that connects through the same public edge as real users and records the delay between an event's created_at and its own receipt time, ideally on a host whose clock you trust. Comparing timestamps from arbitrary user devices to server clocks mixes in clock skew; use it for trends, not absolutes.
SSE Versus WebSockets and Long Polling
Choose by message direction, infrastructure, event rate and what must happen on reconnect
SSE is excellent for server-to-browser updates: notifications, logs, job progress, live scores. Consuming it in a browser takes a few lines, and reconnection is built in. It does not give you a native bidirectional message channel. A chat application can technically POST messages and receive them over SSE, and plenty do, but frequent two-way exchange is usually more natural on a WebSocket, where one connection carries both directions and acknowledgements can be part of the protocol. SSE avoids what WebSockets add (the upgrade handshake, a separate framing protocol, different infrastructure support) at the price of that single direction.
Long polling repeatedly completes and reopens HTTP requests; SSE keeps one response streaming for many events. In the figure, long polling needs four requests for three events and has short windows between requests where nothing is waiting, which is why it needs a cursor to be safe (see Long Polling). Each cycle also repeats request headers and cookies, commonly hundreds of bytes to a few kilobytes depending on your application, whereas an SSE frame adds a few tens of bytes around the payload. Polling a status every second is 86,400 requests per client per day; a stream replaces them with one connection and, at a 15 second heartbeat, 5,760 tiny comments. Neither mechanism provides exactly-once delivery by itself.
Connection limits are a browser and protocol property
Over HTTP/1.1, browsers cap concurrent connections per origin, conventionally at six, and in the common implementations the cap is shared across all tabs. Each open SSE stream occupies one of those slots for as long as it is open. Three tabs with two streams each consume all six, and further fetch calls to that origin queue behind them with no error; the application just seems frozen. HTTP/2 multiplexes streams onto one connection, so the browser limit stops mattering, but the server advertises a concurrent stream limit (100 is a common default) that still bounds you. SSE is a response stream over HTTP, so its behaviour depends on the negotiated HTTP version and on every intermediary's implementation, not on a universal fixed number.
- Streams wanted
- 6
- Slots left for API calls
- 0
- of 6
- Streams waiting
- 0
- none
- Fetch calls
- stall
The practical rules follow. Serve SSE over HTTP/2 or HTTP/3 where you can. Multiplex logical channels over one stream per page, labelled with event: types, instead of opening one stream per widget. If you must stay on HTTP/1.1, put the stream on a separate hostname so it does not consume the slots your API calls need, and coordinate across tabs (for example with a shared worker or a leader tab) so five tabs do not hold five streams.
Choose from the actual requirements: message direction, infrastructure support, event frequency, reconnection behaviour and client environment. Pushing a notification to a page that never talks back favours SSE. A status that changes every few minutes favours plain polling. A collaborative editor favours WebSockets.
Backpressure and Slow Consumers
Bound every connection's queue and decide in advance what happens to a client that cannot keep up
A server may produce events faster than a browser or its network can consume them. Writing indefinitely into an unbounded buffer risks memory exhaustion, and the failure is nasty because it is caused by the weakest clients: a phone on a poor connection holds server memory proportional to how long it stays slow. Take 50 events per second at 400 bytes, which is 20 KB/s, to a client that drains 5 KB/s. The queue grows by 15 KB/s, about 54 MB per hour for that one client, and a thousand like it hold 54 GB. The calculator uses the same numbers by default.
- Queue growth
- 15 KB/s
- Time to hit the cap
- 17.1 s
- cap 256 KB
- Uncapped after 1 h
- 54 MB
- one slow client
- 1,000 such clients
- 54 GB/h
- if every queue is unbounded
The streaming API tells you when to stop. In Node, res.write() returns false once the socket's buffer is past its high-water mark, and drain fires when it has emptied. Over HTTP/2 the same signal comes from per-stream flow control. Respect it, bound the per-connection queue, and define the policy when the bound is hit. There are four defensible choices:
- Drop low-value updates such as a cursor position or a progress tick that the next one supersedes.
- Coalesce to the latest state by key. For a stock-price display, sending only the latest price is acceptable.
- Disconnect with a resync path: send a resync event, close, and let the client fetch a snapshot and reconnect from a fresh cursor.
- Reduce emission frequency for that client, for example by batching to one frame per second.
What is not acceptable depends on the data. For an audit stream, dropping events silently is wrong; the right policies there are disconnect-and-resync from a durable log, or not dropping at all. The class below combines three of the policies: a bounded queue, latest-wins coalescing for keyed state, and a resync disconnect when the bound is exceeded.
// One instance per connection. Bounded queue, latest-wins coalescing by key, disconnect with a resync path.
class SseClient {
constructor(res, { maxQueue = 200 } = {}) {
this.res = res;
this.queue = [];
this.maxQueue = maxQueue;
this.blocked = false;
this.closed = false;
res.on('drain', () => { this.blocked = false; this.flush(); }); // the socket caught up
res.on('close', () => { this.closed = true; this.queue.length = 0; });
}
push(evt) { // evt: { id, event, data, key? }
if (this.closed) return;
if (evt.key) { // state-like updates (a price, a gauge): keep only the newest per key
const i = this.queue.findIndex((q) => q.key === evt.key);
if (i !== -1) { this.queue[i] = evt; return; }
}
if (this.queue.length >= this.maxQueue) return this.disconnect('slow_consumer');
this.queue.push(evt);
this.flush();
}
flush() {
while (!this.blocked && !this.closed && this.queue.length) {
const ok = this.res.write(frame(this.queue.shift()));
if (!ok) this.blocked = true; // write() returned false: stop until 'drain'
}
}
disconnect(reason) {
this.closed = true;
this.queue.length = 0;
this.res.write(frame({ event: 'resync', data: { reason } }));
this.res.end(); // the client fetches a snapshot, then reconnects from its cursor
}
}The general discipline is covered in Backpressure and, for refusing work outright, Load Shedding. The SSE-specific part is that every connection is its own small queue, so the policy has to live per client.
Payloads and exposure
SSE frames are text. Binary payloads must be encoded, and Base64 inflates them by a third (3 bytes become 4 characters), so a 1 MB image becomes about 1.33 MB on the wire. Large payloads should usually be referenced by URL rather than embedded in every event: send {"report":"/reports/881"} and let the client fetch it with a normal cacheable request. Authenticate every subscription and authorize the contents of events, not just the connection. Limit concurrent streams per user, and apply rate limiting to reconnections, since a client in a tight error loop is a self-inflicted load test.
Production Architecture and Observability
Broker fan-out, a catch-up path, deploys that clients can survive, and the metrics that expose failure
At scale, application instances consume updates from a broker and fan them out to the clients connected to them. A durable event store or a compacted state store supports reconnect catch-up. The instances stay stateless apart from their open connections, so any instance can serve any reconnect, and the broker is the piece that makes an event produced on one machine reach a client held by another. The Pub/Sub lesson covers the broker side.
Within an instance, keep a subscription registry keyed by authorized audience or topic, as in the sketch below. The topic name should encode the audience (tenant, user, resource), and authorization must happen before a connection is attached to it. Avoid the shortcut of broadcasting every event to every connection and filtering in the browser: the data has already left your trust boundary by then.
// Subscription registry for one instance: topic -> the connections that are allowed to see it.
const topics = new Map(); // topic -> Set<SseClient>
function attach(topic, client) {
if (!topics.has(topic)) {
topics.set(topic, new Set());
broker.subscribe(topic, (evt) => { // one broker subscription per topic per instance
for (const c of topics.get(topic) ?? []) c.push(evt);
});
}
topics.get(topic).add(client);
client.res.on('close', () => topics.get(topic)?.delete(client));
}
// The topic name encodes the audience, e.g. "tenant:acme:build:42". Authorization happens before
// attach(), so an event is never written to a connection that may not read it.Deploys and reconnection storms
Long-lived streams will be interrupted by routine infrastructure changes: deploys, autoscaling, proxy reloads, certificate rotation. Graceful deployment should close or drain streams in a way clients can recover from. If 50,000 clients lose their connection at once and each waits the same 3 seconds, the replacement instances see about 16,700 new connections per second, each doing authentication and possibly a replay query. Spreading the same clients over ten seconds brings that to about 5,000 per second. Randomising retry: per client and draining an instance's streams gradually both achieve that; the client-side backoff with jitter in the fetch example is the same idea. Rate limits and backoff protect what remains.
const clients = new Set(); // add in attach(), delete on 'close'
process.on('SIGTERM', () => {
server.close(); // stop accepting new streams
const drainMs = 20000;
for (const c of clients) {
setTimeout(() => c.res.end(), Math.random() * drainMs); // spread reconnects over 20 s
}
setTimeout(() => process.exit(0), drainMs + 2000);
});
// On connect, give every client its own retry delay so a mass reconnect does not arrive in lockstep:
// res.write(`retry: ${3000 + Math.floor(Math.random() * 10000)}\n\n`);What to measure
A streaming system fails quietly, so instrument the properties that users feel:
- Active streams per instance and per audience, and stream lifetime distribution; a spike of very short lifetimes means something is cutting connections.
- Event-to-client latency, measured at the server write and by a synthetic client at the edge; the difference between the two is your buffering delay.
- Bytes per connection and queue depth, to catch heavy topics and slow readers early.
- Reconnection rate, with the
Last-Event-IDage on each, and replay lag (how many events each reconnect had to replay). - Slow-consumer disconnects, authorization failures, and event gaps reported by clients that saw a non-contiguous sequence.
When SSE Is the Right Choice
Server-to-browser streams where the client reads and occasionally acts over plain HTTP
SSE fits when updates flow one way and the browser needs them promptly. A CI dashboard streaming build progress and log lines is the textbook case: the stream has a start and an end, ordering matters, and the only thing the client ever sends is the initial request. Notification feeds, live scoreboards, monitoring dashboards and analytics counters share the shape. In all of them one connection replaces a polling loop, and the work moves from the client asking repeatedly to the server holding state.
Online and offline indicators are a common case: a stream can carry "Ana came online" or "Ana went offline" to everyone watching her. SSE only transports those changes. Deciding what online means, including heartbeats, expiry and a grace period for reconnects, is the state model covered in Presence.
It fits less well where the client talks back constantly. Chat needs both directions at low latency, and while POST plus SSE works, you end up with two connection types and two error models; collaborative editing wants a bidirectional channel and is better served by WebSockets. And it is overkill where updates are rare: an order status that changes every few minutes is cheaper to poll than to hold a connection open for. The cost of SSE is connection state on your servers and a recovery story you must build, so use it when freshness justifies both.
The minimum credible first version is the handler and client in this lesson: framed events with IDs, a heartbeat, buffering disabled at the edge, a store that can replay from a cursor with an explicit resync fallback, bounded per-connection queues, and a client that dedupes and recreates a closed stream. The remaining work is scale and operation, not a different protocol. Related lessons: WebSockets, Long Polling, Pub/Sub.
Key Takeaways
Production rules to carry away from this lesson
- SSE is a long-lived HTTP response, and replay exists only if the backend builds it: attach an id to every event, validate the Last-Event-ID cursor, replay from a durable store, and fall back to an explicit resync when the cursor is too old.
- Send a comment heartbeat (typically every 15 to 30 seconds) sized from the shortest idle timeout on the path, and aim for a gap of half that timeout or less.
- Disable buffering and compression for the stream at every hop (proxy buffering is the usual reason streaming does not stream) and measure event-to-client latency from an edge client.
- Bound every connection's queue and decide in advance whether a slow client gets coalescing or a disconnect with a cursor.
- Over HTTP/1.1 browsers allow about six connections per origin, shared across tabs. Prefer HTTP/2 or HTTP/3, multiplex channels on one stream per page, and coordinate tabs if you must stay on HTTP/1.1.
- Spread reconnects after a deploy with per-client retry jitter and gradual draining, because 50,000 clients with the same 3-second wait become about 16,700 connections per second.