Server-Sent Events (SSE) — Middle Level¶
Middle level is where you stop treating SSE as "a magic real-time thing" and start reading the bytes. An SSE stream is not a framing protocol with headers and opcodes like WebSocket — it is a plain, line-oriented, UTF-8 text format sent over an ordinary HTTP response that never ends. If you can produce the exact bytes by hand, you can debug any SSE bug, because everything the browser does is a direct function of what those bytes say.
This file covers the text/event-stream grammar precisely, the automatic reconnection machinery built on Last-Event-ID, the server-side requirements that make streaming actually stream (flushing, buffering, keepalives), and the hard limits you must design around.
Table of contents¶
- The response that never ends
- The
text/event-streamgrammar, field by field - Worked example: real stream bytes
- Automatic reconnection and
Last-Event-ID - Staged reconnect walkthrough
- Server requirements: flush, buffering, keepalive
- The connection-limit problem and HTTP/2
- Hard limits and what SSE cannot do
- SSE vs WebSocket capability table
- Checklist and next step
1. The response that never ends¶
A normal HTTP response has a body of known length: the server sets Content-Length, writes that many bytes, and closes. SSE inverts this. The server sends response headers, then holds the connection open and dribbles out bytes over seconds, minutes, or hours. There is no Content-Length; the body is open-ended.
The headers that make a response an SSE stream:
Content-Type: text/event-streamis mandatory — it is the signal that tellsEventSource(and intermediaries) to parse the body with the event-stream grammar rather than buffering it as a document.Cache-Control: no-cacheprevents proxies and the browser cache from holding the stream or replaying a stale copy.- Under HTTP/1.1 the body is delivered with
Transfer-Encoding: chunked(each write becomes a chunk); the server infrastructure adds this automatically once noContent-Lengthis set.
On the client side, all of this is driven by one object:
const es = new EventSource("/stream");
es.onmessage = (e) => console.log(e.data); // default (unnamed) events
es.addEventListener("price", (e) => update(e)); // named events
es.onerror = (e) => console.warn("disconnected, browser will retry");
EventSource opens the connection, parses the incoming byte stream line by line, dispatches events, and — crucially — reconnects automatically when the stream drops. That last behavior is the single biggest reason to reach for SSE, and it is entirely governed by the wire format below.
2. The text/event-stream grammar, field by field¶
The body is a sequence of events, and each event is a sequence of lines. A line is terminated by \n, \r, or \r\n (all three are legal; \n is conventional). An event is terminated by a blank line. That blank line is the framing — miss it and the client keeps buffering, waiting for an event that never dispatches.
Each non-blank line is a field: everything up to the first colon is the field name, everything after the colon (with a single leading space stripped, if present) is the value. There are exactly four field names the client understands, plus the comment form:
| Field | Purpose | Client behavior |
|---|---|---|
data: | Payload text | Appended to the event's data buffer; multiple data: lines are joined with \n |
event: | Event name | Sets the event type; dispatched to the matching addEventListener (default is message) |
id: | Event ID / cursor | Stored as the stream's "last event ID"; sent back on reconnect via Last-Event-ID |
retry: | Reconnect delay | Integer milliseconds; sets how long the client waits before reconnecting |
: (comment) | Comment / keepalive | Ignored entirely; used to keep the connection warm |
Rules that trip people up:
data:is joined with\n, not concatenated. Twodata:lines become a two-line string. To send one logical newline in your payload, emit twodata:lines. To send JSON, you can put it all on onedata:line.- A field with no colon (a bare line like
data) is treated as a field name with an empty value — legal but almost never what you want. - Unknown field names are silently ignored.
foo: bardoes nothing on the client; it will not error. This is a common source of "my event never arrives" — a typo likeevnt:is dropped without complaint. - The event dispatches on the blank line. At that point the client fires the event with the accumulated
databuffer and the currenteventtype, then resets thedataandeventbuffers (but not the last-event-id, which persists). - A trailing
\ninsidedatais dropped. If your data buffer ends with a newline, the client strips exactly one before dispatch.
The value of id: should not contain a NUL character (\0); if it does, the client ignores that id: line. Everything is UTF-8; a leading UTF-8 BOM on the stream is stripped once at the start.
3. Worked example: real stream bytes¶
Here is a concrete stream, shown as literal bytes (␊ marks each \n so blank lines are visible). Read it as the browser does.
: connected, ping every 15s␊ ← comment line, ignored (used as keepalive)
␊
data: hello world␊ ← unnamed event, data = "hello world"
␊
event: price␊ ← named "price" event
data: {"sym":"AAPL","px":214.7}␊ ← data = that JSON string
id: 1042␊ ← cursor stored; sent back on reconnect
␊
event: price␊
data: line one␊ ← two data lines →
data: line two␊ ← data = "line one\nline two"
id: 1043␊
retry: 5000␊ ← tell client: wait 5s before reconnecting
␊
What the client does with each block:
- First block — a comment. Nothing dispatched. Its only job is to push bytes so proxies and the client register the connection as live.
- Second block — fires an
onmessageevent whoseevent.data === "hello world",event.lastEventId === ""(noid:seen yet). - Third block — fires a
pricelistener;event.datais the JSON string (you callJSON.parseyourself), andevent.lastEventId === "1042". The client now remembers1042. - Fourth block — fires a
pricelistener withevent.data === "line one\nline two"andevent.lastEventId === "1043". Theretry: 5000updates the reconnect timer for all future drops.
Notice there is no length prefix, no checksum, no binary framing. The blank lines are the entire framing protocol. That simplicity is why SSE is trivial to generate from any language — it is just printf-style text with flushes.
4. Automatic reconnection and Last-Event-ID¶
This is the feature that distinguishes SSE from "a fetch that streams." When the TCP connection drops — server restart, load-balancer idle timeout, laptop lid closed, tunnel through a flaky mobile network — EventSource does not fail permanently. It waits a reconnection time and reopens the same URL. The default delay is browser-defined (typically a few seconds); the server can override it at any time by sending a retry: field.
The magic is resumption without data loss, and it works through a single contract:
- Every event the server sends carries an
id:— a monotonic cursor (a sequence number, a Kafka offset, a database row version, a timestamp — whatever your backend can resume from). - The client stores the most recent
id:it saw as its last event ID. - When the connection drops and the client reconnects, it automatically sends an HTTP request header:
- The server reads that header, treats
1043as "the client already has everything up to and including 1043," and replays from 1044 onward before resuming the live feed.
The server is responsible for the replay. EventSource only promises to send you the cursor; it cannot magically recover events it never received. If your handler ignores Last-Event-ID and always starts from "now," reconnects will silently drop every event that occurred during the outage. Correct SSE backends keep a bounded buffer (in memory, Redis, or a log/stream store) keyed by ID so they can seek to Last-Event-ID + 1.
A minimal Node.js handler that honors this:
app.get("/stream", (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", // disable nginx proxy buffering
});
const cursor = Number(req.headers["last-event-id"] || 0);
for (const ev of eventsSince(cursor)) { // replay the gap
res.write(`id: ${ev.id}\n`);
res.write(`event: ${ev.type}\n`);
res.write(`data: ${JSON.stringify(ev.payload)}\n\n`);
}
const keepalive = setInterval(() => res.write(": ping\n\n"), 15000);
const unsub = feed.subscribe((ev) => { // live feed after replay
res.write(`id: ${ev.id}\n`);
res.write(`event: ${ev.type}\n`);
res.write(`data: ${JSON.stringify(ev.payload)}\n\n`);
});
req.on("close", () => { clearInterval(keepalive); unsub(); });
});
Two subtleties worth internalizing:
Last-Event-IDis a request header, not a query parameter. It is added by the browser, not by your JS. You never set it from the client side — you just design your server to read it.- The client keeps sending the same ID until it receives a newer one. If the reconnect itself fails, the next attempt carries the same
Last-Event-ID, so replay is idempotent from the server's point of view — as long as yourid:s are truly monotonic per stream.
5. Staged reconnect walkthrough¶
The diagram below stages a normal session, an outage, and an automatic resume so you can see exactly which byte carries the state across the gap.
The whole recovery hinges on Stage 3's request header. Delete the id: fields from your server output and Stage 3 still happens — the client reconnects — but it sends Last-Event-ID: (empty), the server has no cursor, and Stage 4 replays nothing. The user silently loses whatever happened during the outage. IDs are not decoration; they are the resume protocol.
6. Server requirements: flush, buffering, keepalive¶
SSE fails in subtle ways when some layer between your code and the browser decides to buffer. Buffering is the enemy of streaming: it holds your bytes until "enough" accumulate, which for a slow event feed can be minutes — or forever. Three concrete requirements.
Flush after every event. Writing to the response socket is not enough; many stacks buffer at the application or framework layer. You must flush so the bytes leave the process immediately.
- Node.js
res.write(...)flushes to the OS by default — good — but if compression middleware sits in front, it may buffer (see below). - PHP: call
flush()and oftenob_flush(), and disable output buffering. - Python WSGI: yield from a generator; ensure the server (gunicorn/uWSGI) does not buffer — use an async worker.
- Java: call
response.getWriter().flush()(or use the async Servlet/SseEmitter).
Never compress an SSE stream. Content-Encoding: gzip (or br) buffers input to build compression blocks, which destroys the one-event-at-a-time flow and can hold events indefinitely. Explicitly disable compression for the text/event-stream route. In nginx this means turning gzip off for the location; in app frameworks, exclude the route from compression middleware.
Disable proxy buffering. Reverse proxies buffer response bodies by default to smooth out slow upstreams — exactly wrong for SSE. For nginx, set on the SSE location:
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s; # don't kill a long-lived stream
add_header X-Accel-Buffering no; # or send it from the app
The X-Accel-Buffering: no response header (which nginx honors) is the portable way to say "do not buffer this response" from application code, which is why the Node example above sets it.
Send keepalive comments to defeat idle timeouts. Load balancers, proxies, and some browsers close connections that are silent for too long (commonly 30–120 seconds). A slow event feed — say, an alert channel that emits nothing for ten minutes — looks idle and gets reaped. The fix is a periodic comment line that pushes bytes without dispatching an event:
A :-prefixed line is ignored by the client (no event fires, no data buffer change), but it counts as activity for every intermediary, resetting their idle timers. Send one every 15–30 seconds — comfortably under the tightest idle timeout in your path. This is cheaper and cleaner than sending real "heartbeat" events, because it needs no client handling.
7. The connection-limit problem and HTTP/2¶
The classic SSE gotcha under HTTP/1.1: browsers cap the number of simultaneous connections to a single origin at roughly 6. An SSE stream is a connection that stays open indefinitely. So a page that opens SSE, plus a few tabs to the same site, quickly exhausts the budget — and because SSE connections never close, the seventh request blocks until one frees up. Symptom: images stop loading, XHR/fetch hang, the app appears frozen, all because one long-lived EventSource per tab ate the pool.
Consequences and mitigations under HTTP/1.1:
- One SSE connection per tab, at most. Multiplex all your real-time channels through a single stream (use the
event:field to distinguish channel A from channel B) rather than opening oneEventSourceper feature. - Multiple tabs multiply the problem. Five tabs of your app = five SSE connections = the budget is gone before any tab does normal work. A
SharedWorkerholding oneEventSourceshared across tabs is the standard workaround.
HTTP/2 removes the cap entirely. HTTP/2 multiplexes many logical streams over a single TCP connection, so an SSE stream is just one stream among many — it no longer consumes a whole connection slot. The per-origin concurrent-stream limit is negotiated (commonly 100+), not 6, and normal requests share the same connection. In practice this means: serve SSE over HTTPS/HTTP/2 and the six-connection problem disappears. This is the single biggest operational reason SSE is far more comfortable today than a decade ago — TLS is near-universal, and TLS effectively means HTTP/2.
The connection budget is per origin, so api.example.com and www.example.com have separate pools; sharding SSE onto a subdomain is a legacy HTTP/1.1 trick, but HTTP/2 makes it unnecessary.
8. Hard limits and what SSE cannot do¶
SSE is deliberately narrow. Know the edges before you commit to it.
- One-way only (server → client). There is no client-to-server channel on the SSE stream itself. The client sends data the normal way: a separate
fetch/XHRPOST. This is fine for the common pattern (server pushes updates, client acts via ordinary API calls) but it is not a symmetric duplex link. If you need low-latency bidirectional messaging, that is WebSocket's job. - UTF-8 text only — no binary. The format is line-oriented text. You cannot send raw bytes, protobuf frames, or images directly. To send binary you must Base64-encode it into a
data:line, paying ~33% size overhead and encode/decode cost. If your payloads are fundamentally binary, SSE is the wrong tool. - No true framing for large payloads. Because events are delimited by blank lines, a
data:value that itself contains a blank line must be split across multipledata:lines. Huge single events are awkward and hold the parser until the terminating blank line arrives. - Reconnect replay is your responsibility. As covered in §4, the browser sends
Last-Event-IDbut the server must implement the buffer and seek. Get this wrong and you have "real-time updates that silently lose data on every wifi hiccup" — a bug that never shows in a demo and always shows in production. EventSourcecannot set custom headers. The native browser API allows no custom request headers (e.g., anAuthorizationheader) and only awithCredentialsflag for cookies. Token auth typically rides on a cookie or a query parameter, or you use a fetch-based SSE polyfill that does support headers.- Connection accounting at scale. Every connected client is a held-open socket on your server. 100k concurrent SSE clients = 100k sockets; you need an event-loop / async server (not thread-per-request) and careful file-descriptor and memory budgeting.
9. SSE vs WebSocket capability table¶
Both are "real-time browser transports," but they solve different shapes of problem. This table is the decision aid.
| Capability | SSE (EventSource) | WebSocket |
|---|---|---|
| Direction | One-way, server → client | Full duplex, both directions |
| Underlying protocol | Plain HTTP response (text/event-stream) | HTTP Upgrade to ws:///wss://, own framing |
| Payload type | UTF-8 text only (Base64 for binary) | Text and binary frames natively |
| Auto-reconnect | Built in, no code | Manual — you write reconnect + backoff |
| Resume after drop | Built in via Last-Event-ID (server replays) | Manual — you design your own resume protocol |
| Message framing | Blank-line delimited events; event:/id:/retry: fields | Length-prefixed frames, opcodes, ping/pong |
| Works through HTTP proxies/CDNs | Yes — it is HTTP | Often needs proxy Upgrade support / config |
| Connection cap (HTTP/1.1) | ~6 per origin; each stream holds one | Not subject to the HTTP request pool the same way |
| Connection cap (HTTP/2) | Multiplexed — cap effectively gone | N/A (WebSocket over HTTP/2 is a separate mechanism) |
| Custom request headers | No (native API); cookies/query only | Limited during handshake; app-level after |
| Server implementation | Trivial — write text + flush | More moving parts (frame parsing, ping/pong, close codes) |
| Best fit | Notifications, live feeds, dashboards, progress, log tailing, LLM token streaming | Chat, multiplayer, collaborative editing, live control |
The rule of thumb: if the data flows mostly one way (server telling the client "here's an update"), choose SSE — you get reconnection and resumption for free, and it traverses HTTP infrastructure without special config. If the client must push a steady low-latency stream too (typing, cursor moves, game input), choose WebSocket. Do not reach for WebSocket just because it sounds more powerful; the free reconnect/resume of SSE is a large amount of correctness you would otherwise have to build and test yourself.
10. Checklist and next step¶
Before you ship an SSE endpoint, confirm every line:
- Response sets
Content-Type: text/event-streamandCache-Control: no-cache. - Every event carries a monotonic
id:your backend can resume from. - The handler reads
Last-Event-IDand replays the gap before resuming live. - Each event ends with a blank line; you flush after every write.
- Compression is disabled for the route; proxy buffering is off (
X-Accel-Buffering: no). - A
: pingcomment is sent every 15–30 s to defeat idle timeouts. - The stream is served over HTTP/2 (HTTPS) to avoid the 6-connection cap.
- One
EventSourceper tab, multiplexing channels viaevent:names. - Binary payloads are Base64-encoded, or you've chosen WebSocket instead.
Master these and you can produce and debug an SSE stream byte-for-byte. The senior level goes up a layer: operating SSE at scale — connection fan-out and the C10k/C100k socket budget, broadcasting to millions via a pub/sub backplane, load-balancer stickiness and graceful drain during deploys, and where SSE fits against gRPC streaming and WebSocket in a real architecture.
Next step: Senior level
In this topic
- junior
- middle
- senior
- professional