Server-Sent Events
The Client Has Nothing to Say
Last lesson ended with a warning. A WebSocket is a genuine commitment — a stateful, masked, bidirectional connection with a pub/sub backplane and heartbeats behind it — and we said: reach for one only when the traffic is truly two-way. Then we pointed at all the real-time features where it isn't. A stock ticker. A notification bell. A live dashboard. A progress bar. An LLM typing its answer at you word by word. In every one of those, the server has a firehose of updates and the client has nothing to say back. Standing up a full duplex WebSocket for a one-way trickle, we said, is bringing a freight truck to carry a letter.
This lesson is the letter-carrier. Server-Sent Events (SSE) is server→client push done the cheapest possible way — and the fact that reframes the whole thing is one you've experienced without knowing it: the word-by-word typing in ChatGPT and Claude is Server-Sent Events. Every major LLM streams its tokens to your browser over SSE. It is not a legacy curiosity; it is how the most-used real-time feature of this decade actually works.
And the mechanism is almost anticlimactically simple. There is no upgrade, no 101, no frames, no masking, no new protocol. It's an ordinary HTTP GET whose response simply never ends — the server holds the connection open and writes little text events down it, forever. The browser hands you one object, EventSource, that opens it, parses it, and — the part that's genuinely hard on raw WebSockets — heals it automatically when it drops. Let's build the mental model, then you'll sever a live stream and watch it stitch itself back together.

A GET That Never Ends
Start from something you already know: a normal request/response. The client sends GET /events, the server sends back a response, the connection closes. SSE changes exactly one thing — the server sets a special content type and then never sends the end of the response:
GET /events HTTP/1.1
Accept: text/event-stream
HTTP/1.1 200 OK
Content-Type: text/event-stream ← this header is the entire trick
Cache-Control: no-cache
Connection: keep-alive
That Content-Type: text/event-stream is the whole contract. It tells the browser: this response is a stream, not a document — keep it open and hand me pieces as they arrive. The server then writes text down the still-open connection whenever it has news, and the client receives each piece live. It's the long-polling idea from L1 taken to its natural conclusion: instead of holding the connection for one answer and then closing, hold it open and stream many answers down it, never closing.
On the browser side, consuming this is three lines — and note there is no parsing, no framing, no reconnect logic to write:
const es = new EventSource('/events');
es.onmessage = (e) => appendToken(e.data); // fires for every event the server writes
es.onerror = () => {/* EventSource is already reconnecting for you */};
That's it. EventSource opens the stream, parses each event out of the text, fires your callback, and — if the connection dies — quietly reconnects. Compare that to the WebSocket bill from last lesson (upgrade, frames, masking, your own heartbeat, your own reconnect-with-backoff): for one-way data, SSE deletes almost all of it.
The Wire Format Is Just Text
Open the stream with curl -N and there's no mystery to decode — it's readable text. Each event is a little block of field: value lines, and a blank line ends the event. Here's a real capture from a 15-line server (you'll run it yourself at the end):
retry: 3000
id: 1
event: token
data: word_1
id: 2
event: token
data: word_2
Four fields, and you'll use all of them:
data:— the payload. Everything after the colon. Send it several times in one event and the lines are joined with newlines, so multi-line messages just work.event:— an optional name for this event. Name ittokenand the client listens withes.addEventListener('token', …); leave it out and it arrives ones.onmessage. One stream can carry many event types (token,done,error) on the same connection.id:— an identifier for this event. The browser quietly remembers the last one it saw. This tiny field is the key to the magic in the next section.retry:— how many milliseconds the browser should wait before reconnecting if the stream drops. The server tells the client its own reconnect policy.
And a line beginning with a bare colon, : still here, is a comment — ignored by the client, but it pushes bytes down the pipe, which is exactly how you send a heartbeat to keep the connection (and nervous proxies) from timing out. That's the entire protocol. You could implement the server side with res.write() and string concatenation — and that simplicity, versus a binary frame protocol, is precisely the point.
The Magic: A Stream That Heals Itself
Here's the feature that earns SSE its place, and the one you'd otherwise be hand-building on a WebSocket. Networks drop connections — a phone sleeps, Wi-Fi switches, a proxy times out. On a raw WebSocket, you write the code that notices (heartbeats), waits (backoff + jitter), reconnects, and figures out what was missed. On SSE, EventSource does all of it for you, and the id: field makes the "what was missed" part exact.
Watch the sequence:
- The stream is flowing; the client has received events up through
id: 7. The browser remembers7as its last event id. - The connection drops.
EventSourcedoesn't error out to your app — it waits theretry:interval (default ~3 seconds) and reconnects on its own. - On that reconnect, the browser automatically adds a header you never wrote:
Last-Event-ID: 7. - The server reads that header and resumes from event 8 — replaying anything the client missed during the gap — instead of starting over or losing events.
I verified this against a real server: reconnect with Last-Event-ID: 2 and the stream resumed by sending id: 3 next, not id: 1. Zero missed events, zero duplicated events, and zero lines of client reconnect code. That Last-Event-ID is the same idea as the pagination cursor from §6 — a bookmark of where you were — doing a second tour of duty as a resume point for a live stream.
This is the whole reason "just use fetch with a ReadableStream" isn't the same thing. You can stream bytes with plain fetch, sure — but then you own the reconnect, the backoff, and the resume logic, and you've quietly rebuilt EventSource badly. The self-healing stream is what you're actually buying.
See It: Stream the Tokens
Time to make it real — and to break it on purpose, because the break is where SSE earns its keep.
Type a prompt below and hit stream. The server answers the way an LLM does, one token at a time, and you'll see each token twice: as a word appearing in the answer, and — at the same instant — as a raw wire event scrolling past in the stream pane (id: 7 / event: token / data: … / blank line). That pane is the text/event-stream; you're reading the actual bytes as the words type out.
Now cut the connection mid-answer. Watch two streams side by side: the naive one just dies, its sentence frozen half-finished forever. The EventSource one waits out its retry: countdown, reconnects by itself, and sends Last-Event-ID carrying the id of the last token it got — so the server replays from the very next token and the answer finishes without a single word missing. You can watch that header fly on every reconnect. That's the self-healing stream, in your hands.
Then meet the two traps that make SSE work in dev and fail in prod. Open stream after stream: on HTTP/1.1, the seventh one stalls against the browser's six-connections-per-domain wall — flip to HTTP/2 and they all multiplex onto a single connection and the wall vanishes. And switch on a buffering proxy to watch your smooth stream clump into lurching batches — the notorious works-locally-breaks-behind-the-CDN bug that one X-Accel-Buffering: no header fixes.

So: WebSocket or SSE?
You now have three real-time tools — polling, WebSockets, SSE — and the skill is picking without hand-waving. The single question that decides it is which way does the data flow?
- One direction, server → client? Use SSE. Live dashboards, notification feeds, activity streams, progress updates, stock tickers, and — the marquee case — LLM token streaming. You get push, automatic reconnect with replay, plain-HTTP simplicity, and HTTP/2 multiplexing, without a byte of frame or masking code.
- Genuinely two-way and chatty? Use a WebSocket. Chat with typing indicators, multiplayer game state, collaborative cursors, live trading where the client is constantly sending too. You pay the stateful bill from last lesson because you actually use the up-channel.
- Rare or loosely-timed updates? Just poll (L1). A held connection for something that changes hourly is pure overhead.
Two honest footnotes. First, SSE is text-only — no binary frames — so ship bytes as base64 or JSON, or use a WebSocket if you're streaming, say, audio. Second, SSE has no up-channel at all; when the client needs to send something, it makes an ordinary separate HTTP request, which is completely fine (that request-response you already know). The moment you find yourself wanting a real, chatty up-channel, that's your signal you've outgrown SSE and want a WebSocket.
And notice the quiet reversal. The old instinct was "use WebSocket unless you can't." The modern default has flipped to "start with SSE unless you specifically need two-way," precisely because so much real-time turned out to be one-way — and because the biggest one-way firehose of all, LLM output, chose SSE.
The Three Things That Bite in Production
SSE's simplicity is real, but three sharp edges have ruined plenty of launches. Know them before you ship:
- The HTTP/1.1 six-connection wall. Browsers cap concurrent connections to a single domain at about six on HTTP/1.1. An SSE stream holds one open the entire time it's alive — so a user with six tabs open, or an app that opens six streams, exhausts the pool, and the seventh connection hangs. Worse, it starves your ordinary requests to that domain too, because they're fighting for the same six slots. The fix is HTTP/2, which multiplexes ~100+ streams over one TCP connection and makes the cap effectively disappear — so serve SSE over HTTP/2, or shard across subdomains if you're stuck on 1.1.
- Silent proxy and CDN buffering — the number-one SSE bug. An intermediary that doesn't realize it's a stream may buffer the response, releasing your events in a lump when its buffer fills or the connection finally closes — or not at all. The connection looks open; the tokens just arrive in lurching batches, or never. It works perfectly on localhost and breaks the instant it's behind the corporate proxy or the CDN. Defuse it with
X-Accel-Buffering: no(nginx), by disabling proxy buffering, and by sending a:comment heartbeat every 15–30 seconds so bytes keep flowing. - It's still a long-lived connection. SSE dodges WebSocket's frames and masking, but it does not dodge statefulness: each stream pins a connection to a server for its whole life. At scale you still think about how many open connections a box can hold and, if a stream's server needs to push events that originated elsewhere, the same pub/sub backplane idea from last lesson (lighter here, but present). SSE is cheaper than WebSockets — not free.

Mental-Model Corrections
- "SSE is a dead/legacy technology WebSockets replaced." The exact opposite — it quietly won the biggest real-time use case of the era: every major LLM streams tokens over SSE, and the industry default flipped to SSE-first for one-way data. It never needed a comeback for what it's good at.
- "SSE is just long polling." Long polling closes and re-opens the connection per message (L1). SSE holds one connection open and streams many events down it, and resumes with
Last-Event-ID. It's long polling's successor for server→client, not a synonym. - "You can send messages to the server over the SSE connection." No — it is strictly one-way. To talk back you fire a normal HTTP request on the side. Wanting a real up-channel is the signal to switch to a WebSocket.
- "If SSE works in development it'll work in production." The #1 gotcha is proxy/CDN buffering: smooth locally, batched-or-broken behind real infrastructure. Always test through your actual edge, disable buffering, and heartbeat.
- "Reconnection with SSE is fragile / you have to build it." Reconnect plus
Last-Event-IDreplay is its headline built-in feature — the very thing you must hand-roll on a raw WebSocket. It's SSE's biggest advantage, not a weakness. - "SSE gives you real-time with no infrastructure cost." It's still a long-lived, server-pinned connection that eats an HTTP/1.1 connection slot and can be silently buffered. Lighter than a WebSocket, but not weightless.
Try It Yourself: Curl a Live Stream
Fifteen lines of dependency-free Node give you a real SSE server, and curl lets you watch the stream and prove the replay — no browser needed. Save as sse.js:
// sse.js — a complete Server-Sent Events server. No dependencies.
const http = require('http');
http.createServer((req, res) => {
if (req.url !== '/events') { res.end('open /events with curl -N\n'); return; }
res.writeHead(200, {
'Content-Type': 'text/event-stream', // <- the header that makes it SSE
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // tell nginx: do NOT buffer this stream
});
const last = Number(req.headers['last-event-id'] || 0); // where the client left off
let id = last;
res.write('retry: 3000\n\n'); // reconnect after 3s if we drop
const t = setInterval(() => {
id++;
res.write(`id: ${id}\n`);
res.write(`event: token\n`);
res.write(`data: word_${id}\n\n`); // blank line ends the event
if (id >= last + 4) { clearInterval(t); res.end(); } // (stop after 4, for the demo)
}, 300);
}).listen(9320, () => console.log('SSE server on :9320'));
- Terminal 1:
node sse.js→SSE server on :9320. - Terminal 2:
curl -N http://localhost:9320/events— the-Nmeans don't buffer, so you watch it stream. You should see, arriving live:
That's the wholeretry: 3000 id: 1 event: token data: word_1 id: 2 event: token data: word_2 … (through word_4)text/event-streamwire format, in your terminal. - Now prove the replay — the thing
EventSourcedoes automatically. Reconnect as if you'd already seen event 2, by sending the header the browser would send:
You should see it resume fromcurl -N -H "Last-Event-ID: 2" http://localhost:9320/eventsid: 3, notid: 1:
No missed events, no duplicates — the server picked up exactly where the client left off. That single header is the entire self-healing-stream mechanism, and you just drove it by hand.id: 3 event: token data: word_3 … (through word_6) - See the contract:
curl -sI http://localhost:9320/eventsand confirmContent-Type: text/event-streamandX-Accel-Buffering: noin the headers — the two lines that turn a normal response into a stream a proxy won't wreck.
Recap & What's Next
- SSE is server→client push, done as plain HTTP. A
GETwhose response isContent-Type: text/event-streamand simply never ends — no upgrade, no frames, no masking. The long-polling idea (L1) taken to its conclusion: hold one connection open and stream many events down it. - The wire format is readable text:
data:(payload),event:(type name),id:(remembered by the browser),retry:(reconnect delay), blank line ends an event,:is a heartbeat comment.EventSourceparses it all in three lines of client code. - The self-healing stream is the point. On a drop,
EventSourceauto-reconnects and sendsLast-Event-ID, so the server replays from the exact event you missed — the reconnect-and-resume you'd hand-build on a WebSocket, free, and the pagination cursor (§6) reincarnated. - Pick by direction: one-way → SSE; two-way & chatty → WebSocket; rare → poll. The modern default flipped to SSE-first, and every major LLM streams tokens over SSE.
- Respect the three edges: text-only, the HTTP/1.1 six-connection wall (HTTP/2 fixes it), and silent proxy buffering (the #1 prod bug — disable buffering, heartbeat).
So far, every §7 tool has kept one connection open between a browser and your server — polling, WebSockets, SSE. But some pushes have to cross a company boundary: Stripe needs to tell your backend a payment succeeded; GitHub needs to tell your server code was pushed. There's no browser, no held connection, no client sitting there listening — and you can't keep a socket open to every third party on Earth. So the direction flips one more time: instead of you calling them, they call you. That's Webhooks: We'll Call You, next — and it's where the idempotency and retry ideas from §6 come back to collect.