cf.completefrontend
My progress
FUNDAMENTALS · MODULE 5

Server-sent events

One HTTP response that never finishes: the server keeps writing small text events to it, and the browser's EventSource parses them, reconnects on its own, and resumes where it left off. The simplest push channel when data flows from server to client.

The problem: one request per message is too many

You are building the order tracking page for a food delivery app. After checkout, the page shows a status line: "Order received", then "Cooking", "Rider assigned", "On the way", "Arriving in 2 minutes". The rider's position also moves on a small map every few seconds. The page never sends anything back; it only listens.

In the long polling lesson, you learned how to deliver updates like these quickly: the browser sends a request, the server holds it until there is news, answers, and the browser asks again at once. It works, and it is how many real-time features started.

Now count the work. Every update costs a full HTTP round trip: new request headers (often 500 to 800 bytes with cookies), a new response with its own headers, and a short gap between the answer and the next request where an update can slip by. A rider position every 3 seconds means 20 full requests per minute per user, each carrying a few dozen bytes of real data.

What if the response never ended?

Long polling ends the response after one message because a normal HTTP response has to end. But HTTP never said that. A server can send the headers, write some of the body, wait, write some more, and keep going for an hour. Browsers have always been able to read a body while it is still arriving.

Server-sent events (SSE) turn that into a standard: the browser opens one ordinary GET request. The server answers with Content-Type: text/event-stream and then simply does not finish. Each time it has news, it writes a few lines of text to the open response. The browser reads each event as it arrives and fires a JavaScript event for it.

Two timelines over 30 seconds with five order updates at 4, 10, 16, 22 and 28 seconds. Top, long polling: five held requests, each answered by one update and followed by a new request, so five requests and five sets of headers. Bottom, server-sent events: one request at 0 seconds and one response that stays open, with the five updates written into it as small events of about 50 bytes.long polling: one request per update0 s5 requests, 5 sets of headers, a gap after each answerserver-sent events: one response that stays open0 s30 s1 request, headers once, 5 events of about 50 bytes
Long polling pays a full request and response for every update. An event stream pays for the request once, and each update after that is only its own few bytes.

For the tracking page, that means one request when the page opens and then only data: an update costs about 30 to 60 bytes of stream text, with no new headers and no gap between requests. The browser's built-in EventSource API parses the stream, reconnects when the network drops, and tells the server which event it saw last, so nothing is lost.

Where SSE fits

SSE is part of the HTML standard and works in every current browser. It is the right tool when updates flow mostly one way, from server to browser:

  • Status and progress: order tracking, file processing, deploy and build logs, CI job output.
  • Feeds and counters: live scores, notification badges, stock tickers, "3 new posts" banners.
  • Streaming answers: an AI assistant writing its reply word by word. This one use made SSE popular again in the last few years.

This lesson builds on the short polling and long polling lessons and does not repeat their basics. It covers the EventSource API, the stream format byte by byte, reconnects with Last-Event-ID, the one-way limit and how to work around it, the connection limit of HTTP/1.1 and why HTTP/2 removes it, streaming AI responses with fetch, and running SSE in production. The WebSockets lesson covers the two-way option, and the choosing-realtime lesson puts them all side by side.

The mental model: a radio you tune in once

Picture a radio station. You tune in once, and it keeps broadcasting to you for as long as you listen. You never ask "anything new?": news simply arrives. You cannot talk back through the radio; to request a song, you phone the station on a separate line.

If the signal drops in a tunnel, a good car radio finds the station again by itself. A very good station also keeps a recording of the last few minutes, so when you come back it can play what you missed before it carries on live.

Every part of that picture maps to SSE:

RadioServer-sent events
Tuning in onceOne GET request with Accept: text/event-stream
The broadcastA 200 response whose body never ends
Each news itemOne event: a few field: value lines ending with a blank line
Phoning the stationA separate fetch or form POST for anything the user sends
Finding the station againEventSource reconnects by itself after a drop
Replaying what you missedThe browser sends Last-Event-ID; the server replays newer events

One request, many events

Here is the whole exchange for the tracking page. The browser sends one request. The server answers with headers right away, so the browser knows the stream is open, and then writes events whenever they happen, seconds or minutes apart.

A sequence diagram between the browser and the server. At 0 ms the browser sends GET /api/orders/481/events with Accept text/event-stream. The server answers 200 with Content-Type text/event-stream at once and keeps the response open. At 0.2 seconds it writes event 41, cooking. At 20 seconds it writes a ping comment. At 4 minutes it writes event 42, rider assigned, and at 9 minutes event 43, on the way, all into the same response.BrowserServer0 msGET /api/orders/481/events200 text/event-stream0.2 sid: 41 cooking20 s: ping4 minid: 42 rider assigned9 minid: 43 on the wayone openresponse
The headers arrive at once, so the browser knows the stream is open. After that the server writes an event whenever something happens, seconds or minutes apart, into the same response.

Three things in that picture matter for everything that follows.

  1. It is plain HTTP. No protocol switch, no new port, no special server. Cookies, CORS (the cross-origin permission checks), compression rules, load balancers, HTTP/2, and HTTP/3 all work the way they work for any other response.
  2. The browser does the parsing. Your code receives finished events, never raw bytes. You do not write a parser, a reconnect loop, or a resume protocol.
  3. Data flows one way. The request carries nothing after it starts. Anything the user sends goes in a separate request.

What SSE is not

SSE is easy to confuse with three neighbors. Long polling also holds a request open, but ends it after each message. WebSockets upgrade the connection to a different protocol that carries messages both ways, text or binary. Web Push delivers notifications through the browser vendor's push service even when the page is closed, while SSE only works while the page (or a worker) is open and listening.

Check your understanding · Exercise 1

Which of these features are a natural fit for server-sent events? Select all that apply.

The EventSource API

The browser side of SSE is one small object. You create it with a URL, add listeners, and close it when you are done. That is the entire API surface: one constructor, three properties, three built-in events, and one method.

ts
const source = new EventSource("/api/orders/481/events");

source.addEventListener("open", () => setBanner(null));

// events with no "event:" line arrive as "message"
source.addEventListener("message", (e) => showToast(e.data));

// named events need their own listener; onmessage never sees them
source.addEventListener("status", (e) => {
  const { status, eta } = JSON.parse(e.data);
  renderStatus(status, eta);
});

source.addEventListener("error", () => {
  if (source.readyState === EventSource.CONNECTING) setBanner("Reconnecting");
  else setBanner("Live updates stopped"); // CLOSED: no more retries
});

// when the page or component goes away
source.close();
A complete client. Every event's data is a string; parse JSON yourself.

The parts, one by one

  • new EventSource(url, { withCredentials }): starts the request at once. The URL can be relative. withCredentials: true sends cookies on a cross-origin stream; same-origin streams always send them.
  • readyState: 0 (CONNECTING), 1 (OPEN), or 2 (CLOSED). It is the only way to tell "retrying" from "gave up".
  • open event: the server answered 200 with the right content type. It fires again after every successful reconnect, which makes it a good place to clear a "Reconnecting" banner.
  • message event: an event that had no event: field. e.data is the payload, e.lastEventId the last ID seen, and e.origin the stream's origin.
  • Named events: if the server writes event: status, the browser fires an event called status. Only addEventListener("status", ...) receives it. A common bug is a server that names every event while the client only sets onmessage, and nothing ever shows up.
  • error event: the connection dropped or could not open. It carries no status code and no reason. Check readyState to learn what the browser will do next.
  • close(): stops the stream for good. The browser will not reconnect, and the server sees the connection close.

The three states

Most bugs with EventSource come from not knowing which state it is in. The rules are short: network trouble sends it back to CONNECTING and it retries forever; an HTTP answer it does not like sends it to CLOSED and it stops.

A state machine for EventSource. new EventSource(url) starts in CONNECTING, readyState 0. A 200 answer with text/event-stream moves it to OPEN, readyState 1, and fires open. From OPEN, a network drop or the server ending the response fires error and moves it back to CONNECTING, which waits the retry time and tries again; network errors while connecting keep retrying. A non-200 status or a wrong Content-Type moves CONNECTING to CLOSED, readyState 2, and fires error. Calling close() moves it to CLOSED from any state. Nothing leaves CLOSED.new EventSource(url)network trouble: retrybad HTTP answer: stopCONNECTINGreadyState 0OPENreadyState 1CLOSEDreadyState 2200 + event-streamdrop: error, then waitnon-200, wrong typeor close()close()
Network trouble always leads back to CONNECTING, and the browser keeps trying. An HTTP answer it does not accept, or your own close(), leads to CLOSED, and nothing ever leaves that state.

Note the arrow from OPEN back to CONNECTING labelled "server ends the response". If your server finishes the response normally, the browser treats it as a drop and reconnects after the retry delay. Servers that "close the stream when done" often cause a loop of reconnects. To end a stream on purpose, tell the client with an event (say event: done) and let it call close(), or answer the next reconnect with 204 No Content.

What EventSource cannot do

The API is small on purpose, and its limits shape real designs:

  • GET only, no body. Every parameter goes in the URL: /api/orders/481/events?after=41.
  • No custom headers. You cannot set Authorization: Bearer ... or any other request header. Authentication must use cookies, or a token in the URL (more on the risks in the one-way chapter).
  • Text only. e.data is always a UTF-8 string. Binary data needs base64 (a third bigger) or a separate download.
  • No status code on failure. A 401 and a 500 look the same: one error event and readyState 2. If you need to know which, make a normal fetch to a status endpoint after the failure.

Check your understanding · Exercise 2

The server writes event: score followed by data: {"home":2,"away":1}. The client only has source.onmessage = render. What happens? Pick one.

The text/event-stream format, line by line

The body of an SSE response is plain UTF-8 text with very simple rules. You can write it by hand, read it in the network panel, and test it with curl -N. Knowing the rules exactly matters on the server, where you write it, and in the AI chapter, where you will parse it yourself.

Events are lines, ended by a blank line

An event is a group of lines, each one field: value. A blank line ends the event and tells the browser to fire it. Until that blank line arrives, the browser only collects; it never fires half an event. There are four fields:

FieldWhat it doesCommon mistake
dataThe payload. Several data: lines in one event are joined with a newline (\n).Writing pretty-printed JSON after data:. Its newlines end the line early, so the rest turns into broken fields. Send JSON on one line, as JSON.stringify does by default.
eventA name. The browser fires an event of that type instead of message.Naming events on the server while the client only sets onmessage.
idSets the stream's last event ID. The browser sends it back as Last-Event-ID after a reconnect.Using an ID the server cannot look up later, like a random UUID with no stored history.
retryMilliseconds to wait before reconnecting. Must be digits only.Sending retry: 5s. Anything that is not all digits is ignored.

Two more rules. A line that starts with a colon is a comment: the browser ignores it, and servers use it as a heartbeat (: ping). Any other field name, like user: 7, is ignored too, which lets servers add fields without breaking old clients.

The text of an event stream, line by line, with notes. retry 5000 sets the reconnect wait in milliseconds; the blank line after it fires nothing because there is no data. id 42 sets the resume point, event status picks the listener, data with a JSON object is the payload, and the blank line fires a status event. A line starting with a colon, ping, is a comment and is ignored. Two data lines, line one and line two, are joined with a newline, and the blank line after them fires a message event.retry: 5000reconnect wait, in msno data: nothing firesid: 42the resume pointevent: statusfires "status"data: {"eta":"18:35"}the payloadfires the event: pingcomment, ignorednothing to firedata: line onetwo data lines...data: line two...joined by \nfires "message"
A blank line is what fires an event, and only if some data was collected. Comments and blocks without data change nothing on screen.

The small parsing rules that bite

  • One space after the colon is removed. data: hi and data:hi both give hi. data: hi (two spaces) gives hi with one space.
  • A line with no colon is a field with an empty value. A bare data line adds an empty line to the payload.
  • Line endings can be \n, \r\n, or \r. The browser accepts all three, so a server on any platform is fine. Your own fetch parser must accept all three too.
  • An event with no data does not fire. But if it had an id, the last event ID still updates. Servers use that to move the client's resume point without sending a payload.
  • id: with an empty value resets the last event ID. The next reconnect then sends no Last-Event-ID header at all.
  • An id that contains a NUL character (\0) is ignored. The last event ID keeps its old value.
  • An unfinished event at the end of the stream is thrown away. If the connection drops before the blank line, that event never fires. This is why resume by ID matters.
  • The text is always UTF-8. A charset in Content-Type is ignored.

Step through a real stream below. Watch the three buffers the browser keeps (the event type, the data, and the last ID) and notice exactly which line makes an event fire.

How the browser reads an event streamStep 1 of 11: A comment

A step-through of eleven lines of an event stream on the left and the browser's buffers on the right: the event type, the data, and the last event ID, plus the events fired so far. A comment changes nothing. id 7, event score and two data lines fill the buffers, and a blank line fires a score event with both data lines joined by a newline. data:hi and a blank line fire a message event that still carries ID 7. id 8 and a blank line fire nothing but move the last ID to 8. retry 3s is ignored because it is not all digits.the stream: helloid: 7event: scoredata: {"home":2,data: "away":1}(blank line)data:hi(blank line)id: 8(blank line)retry: 3sevent type(empty)data(empty)last event ID(none)fired: 0
A line that starts with a colon is a comment. The browser skips it, and nothing changes.

A comment: A line that starts with a colon is a comment. The browser skips it, and nothing changes.

How small it is

The framing is tiny. data: is 6 bytes and the blank line ending is 2 more, so a 40-byte JSON update costs about 48 bytes, or about 55 with an id. A long-poll response for the same update repeats the status line and response headers each time, often 200 to 400 bytes, plus a new request with cookies. Over HTTP/2, header compression shrinks those headers, but SSE still sends them only once per stream.

Check your understanding · Exercise 3

The server writes data: first, then data: second, then a blank line. What does e.data contain? Pick one.

Automatic reconnects and Last-Event-ID

Long-lived connections drop. A phone moves from Wi-Fi to 4G, a laptop sleeps, a load balancer closes connections older than an hour, a deploy restarts the server. With long polling or WebSockets, you write the code that notices and reconnects. With EventSource, the browser does it, and it also tells the server where to resume.

What the browser does after a drop

  1. It sets readyState to CONNECTING and fires error.
  2. It waits the reconnection time: a few seconds by default (the spec leaves the exact number to the browser), or whatever the server last sent with retry:.
  3. It sends the same GET again. If the stream had a last event ID, the request carries it in a Last-Event-ID header, for example Last-Event-ID: 42.
  4. If the answer is 200 with text/event-stream, it sets readyState to OPEN, fires open, and carries on parsing. If it is a network error again, it goes back to step 1.

The server's job is the other half. When a request arrives with Last-Event-ID: 42, it first sends every event after 42 that it still has, then switches to live events. The user sees no gap and no duplicate.

The replay buffer is your job

The header is only a number from the past. Resuming works only if the server can answer "what happened after 42?". That needs two things:

  • Ordered IDs. Use a sequence that only goes up within the stream: a per-order counter, a database row ID, a Redis Streams entry ID, a Kafka offset. Random UUIDs cannot answer "after".
  • A short history. Keep recent events per topic: the last 5 minutes, or the last 1,000 events, in memory, Redis, or the database. Most drops last seconds, so a small window covers almost all of them.

When the ID is older than the history, do not pretend. Send one event like event: reset that tells the client to fetch a fresh snapshot (GET /api/orders/481), then continue live. A client that cannot resume should reload state, never guess.

Try it below. Drop the connection between events, then turn off the replay buffer or the id: lines and drop it again. Watch which events the client never sees.

Server
The server has written 0 events. The client received 0, of which 0 were replayed after a reconnect. Last request: GET /events. Connected. Write a few events, then drop the connection.server wroteclient sawGET /eventsConnected. Write a few events, then drop the connection.
Green outlined events arrived live; plain green ones were replayed after a reconnect. Turn off either server feature and a drop leaves a hole that nothing fills.

When the browser stops retrying

Automatic reconnects only cover network errors and normal ends of the response. The browser gives up for good, with readyState CLOSED, when the reconnect gets:

  • Any status other than 200, after redirects are followed. That includes 204 (the polite "stop"), 401 after a session expires, and also 502 or 503 from a load balancer during a deploy.
  • A Content-Type other than text/event-stream. For example an HTML error page from a proxy.
  • Your own close() call.

The 503 case surprises teams. A deploy that briefly returns 503 turns every open stream into a dead one, and nothing reconnects until the user reloads. Production clients handle CLOSED themselves: wait with backoff and jitter (see the retries lesson), check the session with a normal fetch, then open a new EventSource.

Reconnect storms

The built-in retry is simple. The spec allows browsers to add backoff, but you cannot count on growing delays or any randomness. When a server holding 50,000 streams restarts, 50,000 clients come back within the same second or two, and each one asks for a replay. That spike can knock over the next server too.

Two fixes work well together. Send each client a slightly different retry: value, say a random number between 2,000 and 8,000, so reconnects spread over several seconds. And before a planned restart, drain servers slowly: stop routing new connections to them, then close existing streams in batches over a minute or two.

Check your understanding · Exercise 4

Your SSE server sends id: lines, and clients reconnect fine after Wi-Fi drops. After each deploy, though, every user's live updates stop until they reload. Which explanations fit? Select all that apply.

One way only: sending, auth, and when to use fetch instead

SSE carries data from server to browser and nothing else. Once the request starts, the browser cannot add anything to it. For most products that is fine, because users send a few things and receive many. This chapter covers the three questions that one-way design raises: how the user sends things, how the stream knows who the user is, and what to do when EventSource is too limited.

Sending: a normal request on the side

When the user cancels the order, the page sends POST /api/orders/481/cancel with fetch, exactly as it would without SSE. The server saves the change and publishes it, and the stream delivers event: status with cancelled to every open tab, including the one that clicked.

That echo is useful: all tabs and devices learn about the change the same way. To avoid showing it twice in the tab that made it, send a client-made request ID with the POST and include it in the event, so that tab can recognize its own change and skip it. Over HTTP/2, the POST and the stream share one connection, so the side request costs no extra handshake.

Auth without headers

Because EventSource cannot set headers, the usual Authorization: Bearer token is out. Three patterns work:

  • Session cookie (best when you can). Same-origin streams send cookies automatically. For a stream on another origin, like events.example.com, use withCredentials: true and have the server answer with Access-Control-Allow-Origin set to the exact page origin plus Access-Control-Allow-Credentials: true. A wildcard * origin does not work with credentials.
  • Short-lived ticket in the URL. The page calls POST /api/stream-tickets (with its normal auth) and gets back a random ticket valid for 30 seconds and usable once. It then opens /events?ticket=.... Never put the long-lived access token in the URL: URLs end up in server logs, proxy logs, and analytics.
  • Read the stream with fetch. Then you can set any header you like. This is the next option.

Also plan for expiry. A stream opened with a valid session can stay open long after the session ends. Have the server check expiry on its side and end the stream, or send an event: reauth, when the session runs out.

Reading the stream with fetch

fetch can read any response body as it arrives, through res.body, a ReadableStream. Point it at an event-stream response and you get POST, a request body, custom headers, and an AbortController to cancel. The server writes exactly the same text/event-stream format.

The price is that the browser no longer helps. You parse the lines yourself (or use a small library such as eventsource-parser or @microsoft/fetch-event-source), you write the reconnect loop, and you send the last event ID yourself. The AI chapter shows this code in full.

A comparison matrix of EventSource, fetch streaming, WebSocket and long polling. Server pushes: all four yes. Client sends: EventSource no, fetch once with the request body, WebSocket yes, long polling with each poll. POST and custom headers: EventSource no, fetch yes, WebSocket no, long polling yes. Automatic reconnect and resume by ID: only EventSource has them built in; the others are do it yourself. Plain HTTP: yes for all but WebSocket.EventSourcefetchWebSocketlong pollserver pushesyesyesyesyesclient sendsnoonceyeseach pollPOST, headersnoyesnoyesauto reconnectyesDIYDIYDIYresume by IDyesDIYDIYDIYplain HTTPyesyesnoyes
EventSource is the only one with reconnect and resume built in, and the only one that cannot send a body or headers. fetch streaming swaps those: full control of the request, but recovery is your code.

Decision The server needs to push updates to the page. Which client mechanism should carry them?

EventSource

new EventSource(url) with a GET request

Server writes text/event-stream

Browser parses, reconnects, sends Last-Event-ID

Pros

  • Least code: reconnect and resume are built in
  • Plain HTTP: cookies, CORS, HTTP/2, proxies all work
  • Easy to debug with curl -N and the network panel

Cons

  • GET only, no custom headers, text only
  • No backoff or jitter on retries; a non-200 stops it for good
  • One-way: the user's actions go through separate requests

fetch with a ReadableStream

fetch with any method, headers, and body

Read res.body chunk by chunk and parse events yourself

Write your own retry with the last ID

Pros

  • Any auth header, POST bodies, AbortController
  • Same server format as EventSource
  • The natural fit for one request, one streamed answer

Cons

  • You own parsing, reconnects, and resume
  • Easy to get chunk boundaries wrong
  • Same one-way limit

WebSocket

new WebSocket(url) upgrades the connection

Both sides send messages at any time, text or binary

Pros

  • Two-way with low overhead per message
  • Binary frames
  • One connection for everything the page does

Cons

  • No built-in reconnect, resume, or heartbeat
  • A separate protocol for proxies, auth, and load balancers
  • Stateful servers to scale

Long polling

Repeated GET requests the server holds until there is news

Pros

  • Works through almost any proxy or corporate network
  • Each response is a normal, finished response

Cons

  • A full request per message
  • Gaps between requests need cursors to avoid losing events

What we pick: Use EventSource for long-lived, one-way feeds on the same site with cookie auth: tracking, notifications, dashboards, logs. Use fetch streaming when one request produces one streamed answer, or when you need POST or headers: AI chat, report generation, uploads with progress events. Move to WebSockets when the client also sends often, like chat with typing indicators, games, or collaborative editing. Keep long polling as a fallback for networks that break streaming.

Check your understanding · Exercise 5

A dashboard on app.example.com opens new EventSource("https://events.example.com/stream", { withCredentials: true }). It fails with a CORS error. Which server responses would fix it? Pick one.

HTTP/1.1, HTTP/2, and the six-connection limit

An SSE stream holds its connection for as long as the page is open. On HTTP/1.1 that is a serious cost, and it causes one of the strangest bugs in web apps: "the site freezes when I open it in a few tabs".

Six per host, shared by every tab

Over HTTP/1.1, a connection carries one request at a time. To load pages faster, browsers open several connections to the same host, but cap them at 6 per host. That cap is shared by every tab and window of the same browser profile.

An open EventSource keeps one of those six busy forever. Open the dashboard in 6 tabs and all six are taken by streams. Now every other request to that host, in any tab, waits in a queue: API calls, images, the seventh tab's own page load. Nothing fails with an error; things simply stop loading.

MDN documents this as a known limit, and the Chrome and Firefox bugs asking to raise it for SSE were closed as "won't fix". The fix is on your side.

HTTP/2 and HTTP/3: one connection, many streams

HTTP/2 sends many requests over one connection at the same time, each as a separate stream of frames. An SSE response is just one more stream. The server announces how many streams it allows at once with SETTINGS_MAX_CONCURRENT_STREAMS, usually 100 or 128. HTTP/3 does the same over QUIC, its UDP-based transport, which sets its own stream limits.

So with HTTP/2, 10 tabs with one stream each use 10 of about 100 streams on a single connection, and every other request flows next to them. Try both protocols below.

Protocol
Tabs
Streams per tab
HTTP/1.1 with 3 tabs and 1 stream per tab: 3 open streams. 3 of 6 connections left for everything else on this host.six HTTP/1.1 connections to the hosttab 1tab 2tab 3freefreefreeother requestsAPI call3 connections left for everything else
Over HTTP/1.1 each open stream holds a whole connection, and all tabs share the same six.

What to do in practice

  • Serve SSE over HTTP/2 or HTTP/3. Most CDNs and cloud load balancers do it by default for HTTPS. Check the Protocol column in the network panel: h2 or h3, not http/1.1. What matters is the hop between the browser and the first server it talks to; the CDN can speak HTTP/1.1 to your origin.
  • One stream per tab. Do not open an EventSource per widget. Open one stream and send every topic over it with event names: event: order, event: chat, event: badge. Even on HTTP/2, each stream costs memory and a slot on your server.
  • One stream per browser, if you need to go further. One tab becomes the leader (the Web Locks API, navigator.locks.request, picks one safely), holds the stream, and shares events with the other tabs through a BroadcastChannel. A SharedWorker can also own a single stream for all tabs, though Chrome on Android only supports shared workers from version 148 (2026), so older Android browsers need the leader tab. The cross-tab sync lesson covers these patterns.
  • Close streams in hidden tabs if updates there are not useful, and reopen with ?after= the last ID when the tab is visible again.

Check your understanding · Exercise 6

Your app runs on HTTP/1.1 and each tab opens 2 EventSource streams (one for notifications, one for chat). At how many open tabs do API requests to that host start queueing? Pick one.

Streaming AI responses

AI chat brought SSE back into every frontend interview. A language model writes its answer one token at a time (a token is roughly a short word or part of a word, about 4 characters of English). Models often produce 30 to 100 tokens per second, so a 600-token answer takes 6 to 20 seconds to finish.

Waiting for the whole answer would leave the user staring at a spinner for 15 seconds. Streaming shows the first words after the time to first token, often well under 2 seconds, and the user starts reading while the rest arrives. The total time is the same; the wait the user feels is much shorter.

What arrives on the wire

The big model APIs stream in the text/event-stream format. OpenAI's Chat Completions API with stream: true sends unnamed events, each data: line a JSON chunk with the next piece of text, and ends with the literal line data: [DONE]. Anthropic's Messages API uses named events: message_start, content_block_start, many content_block_delta events carrying text, content_block_stop, message_delta, and message_stop, with ping events mixed in.

The browser never calls those APIs directly, because that would expose your API key. The usual chain is: browser to your backend, backend to the model provider. Your backend reads the provider's stream and writes its own stream to the browser, often in a simpler shape like event: delta with data: {"text":"Hel"}, plus your own event: done with usage and a message ID.

Why fetch, not EventSource

The request is a POST with a body: the conversation so far, often many kilobytes, plus settings like the model name. It usually needs an auth header too. EventSource can do neither, so almost every AI chat client reads the stream with fetch and parses it itself:

ts
const controller = new AbortController(); // the Stop button calls abort()
const res = await fetch("/api/chat", {
  method: "POST",
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
  body: JSON.stringify({ conversationId, message }),
  signal: controller.signal,
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);

const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  // CRLF and CR become LF; a final CR waits, since its LF may be in the next read
  buffer = (buffer + value).replace(/\r\n|\r(?!$)/g, "\n");
  const parts = buffer.split("\n\n");
  buffer = parts.pop() ?? ""; // the last part may be an unfinished event
  for (const raw of parts) handleEvent(parseEvent(raw)); // event name, data, id
}
Chunks from the network do not line up with events: one read can hold half an event or three of them. Keep the unfinished tail in the buffer for the next read.

Two details in that code are where hand-written parsers usually break. A network chunk can end in the middle of an event, or even in the middle of a multi-byte character; TextDecoderStream handles the characters, and the buffer handles the events. And servers may use \r\n line endings, so normalize them before splitting, even when a \r\n pair is cut in half between two reads.

There is a useful hybrid. The POST only creates the answer and returns its ID ({ "messageId": "m-91" }), and the client then opens new EventSource("/api/messages/m-91/stream"). You get the browser's reconnect and Last-Event-ID resume for free, at the cost of one extra round trip and a server that stores the answer as it is generated.

Making the streamed answer feel good

A wireframe of an AI chat window. The user's question, How does SSE reconnect, sits in a bubble on the right. Below it, the assistant's answer is half written and ends with a cursor. At the bottom are the message box and a Stop button. Notes point at the answer: aria-busy true while streaming, and render once per frame; and at the Stop button: Stop calls abort.AssistantHow does SSE reconnect?It waits the retry time,then sends the last IDin a Last-Event-IDheader, and theAsk anythingStoparia-busy=truewhile streamingrender onceper frameStop callsabort()
While the answer streams, it is marked busy and updated at most once per frame. The Stop button aborts the fetch, and the server cancels the model call when it sees the connection close.
  • Render once per frame, not once per token. At 60 events per second, re-rendering a long Markdown message for every event wastes the main thread and makes scrolling stutter. Append pieces to a buffer and flush it to state in a requestAnimationFrame callback.
  • Tolerate half-written Markdown. Most of the time the text ends mid-sentence, inside an open code block, or after a lone **. Use a renderer that handles unfinished input, or close open blocks temporarily before rendering.
  • Scroll only if the user is at the bottom. If they scrolled up to reread, do not drag them down on every token.
  • A real Stop button. It calls controller.abort(). The server must notice the closed connection and cancel the model call, or you keep paying for tokens nobody will read.
  • Handle drops in the middle. Either resume (the hybrid above, or your own retry with the last event ID) or keep the partial text and offer "Continue" or "Retry". Never silently restart from the beginning: the user sees the text repeat.

The lab below streams a short answer. Compare how many renders each strategy causes, and drop the connection halfway to see resume against restart.

Render
After a drop
Left, the network reads: POST /api/chat. Right, the answer on screen: nothing yet. 0 events, 0 renders. Press Send to stream the answer.network readsPOST /api/chaton screenAssistantevents: 0 renders: 0Press Send to stream the answer.
One network read often holds several events. Rendering once per frame turns many events into one update; resuming from the last ID continues with the next word.

Check your understanding · Exercise 7

Your AI chat streams fine on your laptop, but in production the whole answer appears at once after 12 seconds. What could cause it? Select all that apply.

Running SSE in production

A response that stays open for an hour meets every box between the browser and your code: CDNs, load balancers, reverse proxies, compression layers, and the server framework itself. Each was built for responses that finish in milliseconds. Getting SSE to production is mostly about telling each of them that this one is different.

The server side in 20 lines

ts
app.get("/api/orders/:id/events", async (req, res) => {
  if (!(await canSeeOrder(req.user, req.params.id))) return res.status(403).end();
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    "X-Accel-Buffering": "no", // tell Nginx not to buffer this response
  });
  let last = Number(req.headers["last-event-id"] ?? req.query.after ?? 0);
  const send = (e) => {
    if (e.seq <= last) return; // already sent by the replay
    last = e.seq;
    res.write(`id: ${e.seq}\nevent: ${e.type}\ndata: ${JSON.stringify(e.data)}\n\n`);
  };
  const live = bus.subscribe(`order:${req.params.id}`); // subscribe (and buffer) before replaying
  for (const e of await history.since(req.params.id, last)) send(e);
  live.onEvent(send);
  const ping = setInterval(() => res.write(": ping\n\n"), 20_000);
  req.on("close", () => { clearInterval(ping); live.unsubscribe(); });
});
Check access, send the right headers, replay from the last ID, then go live. Subscribing before the replay closes the gap where an event could land between the two, and send drops any event the replay already covered.

Each line answers a real failure. The access check runs on every connect and reconnect, not just the first. The headers stop caches and proxies from holding events.

The replay reads the header first and the ?after= parameter second, and send skips anything already sent. The heartbeat keeps idle timeouts away. The close handler frees the subscription; without it, a server leaks a listener for every tab that ever closed.

Every hop on the path

A layered architecture. At the top, three browser tabs, each with one stream. Below, a CDN or load balancer speaking HTTP/2 with an idle timeout, then Nginx with buffering turned off. Below that, three SSE servers holding the open streams. At the bottom, the order service publishes events to a pub/sub layer such as Redis or NATS, which delivers them to every SSE server and also writes them to a history store that keeps the last five minutes for replays.Browsertab 1tab 2tab 3CDN / LBHTTP/2, idle timeoutNginxbuffering offSSE server Aopen streamsSSE server Bopen streamsSSE server Copen streamshistorylast 5 minpub/subRedis or NATSorder servicepublishes
Every hop must let a long response through: no buffering, and a heartbeat inside each idle timeout. Pub/sub lets any server deliver any event, and the shared history lets any server answer a reconnect.
  • Buffering. Nginx buffers proxied responses by default, so events pile up and arrive in bursts. Turn it off per response with X-Accel-Buffering: no or per route with proxy_buffering off. Check compression too, and make sure your framework flushes each write.
  • Idle timeouts. Proxies close connections that are quiet too long: Nginx's proxy_read_timeout defaults to 60 seconds, and so does the idle timeout of an AWS Application Load Balancer. A comment line every 15 to 30 seconds keeps the connection active.
  • Maximum connection age. Some load balancers and CDNs close any connection after a fixed time no matter what. The client reconnects with Last-Event-ID, so this is harmless if replay works.
  • Fan-out between servers. With 3 app servers, the event "order 481 is on the way" happens on one, but the customer's stream might be on another. A pub/sub layer (Redis pub/sub, NATS, Kafka) delivers each event to every server, and each server writes it to its own open streams. With shared history, any server can serve any reconnect, so you do not need sticky sessions (pinning each user to one server).

Capacity

An idle stream costs little: a socket and some memory, often a few kilobytes to tens of kilobytes depending on the runtime and TLS. Event-loop servers (Node.js, Go, Rust, Netty) hold tens of thousands of streams per process. Thread-per-request servers do not: one blocked thread per open stream runs out fast. Plan file descriptor limits, and watch "open streams per server" as a metric next to CPU.

Failure modes at a glance

What users seeLikely causeFix
Updates arrive in bursts, or all at the endProxy buffering, compression, or no flushX-Accel-Buffering: no, flush per event, check compression
The stream drops every 60 secondsIdle timeout on a proxy or load balancerHeartbeat comment every 15 to 30 seconds
Live updates stop after a deployReconnect got 502 or 503, so EventSource closedHandle CLOSED with backoff and a new EventSource
Missing events after a reconnectNo id: lines, or no history to replayOrdered IDs plus a replay buffer; reset when too old
The app hangs with many tabsHTTP/1.1 connection limitHTTP/2, one stream per tab or per browser
Reconnects every few seconds foreverServer ends the response after each batchKeep the response open; stop with 204 or a done event
Server memory grows all daySubscriptions not removed on closeClean up in the request's close handler

Mobile and background tabs

Phones suspend background pages to save battery, and their connections go quiet or die. When the page comes back, the stream may look OPEN but be dead. Listen for visibilitychange: when the page becomes visible after a long time hidden, close the stream and open a fresh one with ?after= the last ID. For desktop tabs, closing hidden streams is optional and saves server capacity.

In real products

  • Wikimedia EventStreams publishes every edit across Wikipedia as a public SSE feed at stream.wikimedia.org, with Last-Event-ID resume.
  • Firebase Realtime Database offers its REST streaming API as an event stream: send Accept: text/event-stream and receive put and patch events.
  • Mastodon exposes its streaming API (new posts, notifications) over both SSE and WebSockets.
  • OpenAI and Anthropic stream model output in the event-stream format, which is why most AI chat frontends parse it.
  • The Model Context Protocol (MCP), used to connect AI tools, can carry server messages as SSE in its HTTP transport.

Check your understanding · Exercise 8

Your stream works locally. Behind the company load balancer, it drops exactly every 60 seconds when no orders change. What is the best fix? Pick one.

Using SSE in a system design interview

Real-time questions come up in most frontend system design interviews: a news feed with new-post banners, a notification center, a live dashboard, an AI chat, a collaborative editor. The interviewer wants to see you pick a transport for a reason and know what it costs.

When to bring it up

Ask one question first: which way does the data flow, and how often? If the server pushes and the client mostly listens, say SSE and explain why it beats polling (no wasted requests, no interval delay) and why it is simpler than WebSockets (plain HTTP, built-in reconnect and resume). If the client also sends many messages per second, point to WebSockets. The flowchart below is the reasoning to say out loud.

A decision flowchart. First: may the page be closed when the update matters? Yes: Web Push. No: does the client send often? Yes: WebSocket. No: does the request need POST, a body, or headers, as in AI chat? Yes: fetch with a readable stream. No: EventSource. A side note: if a network breaks streaming, fall back to long polling.page may be closed?Web Pushclient sends often?WebSocketneeds POST or headers?fetch streamEventSourceyesyesyesnononostream blocked?fall back tolong polling
Ask the questions in this order and say each answer out loud. Most one-way features end at EventSource; AI chat ends at fetch streaming.

What to draw

  • The browser with one EventSource (or one fetch stream for AI chat) to an SSE endpoint, and a separate arrow for POST actions.
  • The event format: one line like id: 42, event: status, data: {...}. Saying "each event has an ID" sets up the resume answer.
  • Behind the endpoint: the stream servers, a pub/sub layer, and a small replay store keyed by topic and sequence number.
  • On the client: one stream per tab with topics multiplexed by event name, feeding a store that the UI reads.

How deep to go by level

LevelWhat a strong answer includes
JuniorSSE is one long HTTP response the server writes events to; EventSource reconnects by itself; it is one-way, so user actions are normal requests.
Mid-levelThe event fields; Last-Event-ID with ordered IDs and a replay buffer; GET only and no headers, so cookie auth or fetch streaming; the HTTP/1.1 six-connection limit and HTTP/2.
SeniorNon-200 closes the stream for good, so the client owns recovery with backoff; reconnect storms and spread retry values; proxy buffering and idle timeouts; pub/sub fan-out and capacity per server; one stream per browser with leader election; streamed AI rendering, cancel, and accessibility.

Check your understanding · Exercise 9

You are designing a notification center for a web app. Which statements would strengthen your answer? Select all that apply.

Summary

  • Server-sent events are one HTTP response that never finishes: the server writes text/event-stream events, and the browser fires one JavaScript event per blank-line-terminated block.
  • The format has four fields: data (joined with newlines), event (the listener name), id (the resume point), and retry (the reconnect wait). Lines starting with a colon are comments, handy as heartbeats.
  • EventSource reconnects by itself after network drops and sends Last-Event-ID, but resume only works if the server has ordered IDs and a replay buffer. Any non-200 answer closes it for good, so production clients handle CLOSED themselves.
  • It is one-way, GET only, with no custom headers: user actions go in separate requests, and auth uses cookies or a short-lived ticket.
  • On HTTP/1.1 each stream holds one of 6 connections per host shared by all tabs. Serve SSE over HTTP/2 or HTTP/3 and open one stream per tab.
  • AI chat streams the same format over fetch with a ReadableStream, because it needs POST and headers. Batch renders per frame, support Stop, and never silently restart a broken answer.
  • In production, turn off proxy buffering, send heartbeats under the idle timeout, fan out events with pub/sub, and clean up subscriptions when streams close.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

You are designing a live order tracking page. Why choose server-sent events over WebSockets or long polling?

Question 2

How does an SSE client avoid missing events when the connection drops?

Question 3

How do you stream an AI chat answer when the request needs a POST body and an Authorization header?

Question 4

Users say the app freezes when they open it in several tabs. You use SSE. What is happening and how do you fix it?

Question 5

After every deploy, live updates stop until users reload. What is wrong?

Practice it

These design problems lean on server-sent events.

References

Read next