Frontend System Design/Real-time Updates
Lesson 18 of 18 · Episode 18

Real-time Updates — Server-Sent Events

One open HTTP response, a stream of named text events, and browser-native reconnection — without WebSockets.

Server-Sent EventsEventSourceStreamingReconnection
Watch on YouTube ↗

Polling keeps reopening the conversation just to ask whether anything changed. Server-Sent Events flips that model: make one HTTP request, leave its response open, and let the server write text events whenever it has something new. It is server push without introducing a WebSocket protocol.

Server push

One request, one response, many events

Short polling pays for a full request cycle on every interval. Long polling waits longer, but it still eventually returns and starts over. SSE keeps a single response alive. The first request is client-initiated; after that, fresh data flows server → client whenever it is ready.

One request · many eventsSTREAM OPEN
▱
Browser
GET /events
Client opens one request
persistent response channel
▤
Server
EVENT 41
waiting…
EVENT 42
waiting…
EVENT 43
waiting…

The handshake is ordinary HTTP. The important signal is the response header Content-Type: text/event-stream. It tells the browser to expect a UTF-8 event stream instead of one complete JSON document.

A minimal SSE response
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

data: {"price":48.12}

data: {"price":48.19}

Still HTTP
There is no protocol upgrade. The server simply takes a long time to finish the response and flushes events into its body as they become available. That lets SSE reuse normal HTTP auth, observability, routing, and infrastructure — with a few buffering caveats we will meet below.
PollingServer-Sent Events
ConnectionRepeated request/response cyclesOne long-lived HTTP response
Who sets the cadence?The client interval or retry loopThe server writes when data exists
Empty workShort polls can return no updateNo event is sent until there is something to say
RecoveryApplication-owned retry and cursor logicNative EventSource retry + Last-Event-ID
DirectionClient asks, server answersServer → client after setup
On the wire

Four fields and one blank line

SSE framing is deliberately small: lines of text grouped into events. A blank line dispatches the complete event. The browser understands four field names; unknown fields are ignored. Explore each one:

An event, line by linePARSER VIEW
RAW EVENT BLOCK
blank line → dispatch
data
The payload

UTF-8 text. Multiple data lines are joined with newlines before dispatch.

browser → event.data

Named events multiplex one connection

Without an event field, the browser dispatches the payload as a normal message. Add a name and one stream can route several update types without opening one connection per feature.

One stream, three kinds of update
event: message-created
data: {"id":"m-81","text":"Hello"}

event: user-typing
data: {"userId":"u-4"}

: keep-alive

event: user-stopped-typing
data: {"userId":"u-4"}

Heartbeats are comments
A line beginning with : is ignored by the event parser, but its bytes keep the connection active through intermediaries that would otherwise close an idle stream.
Browser APIs

EventSource is convenient — fetch is flexible

Native EventSource gives the browser enough information to parse events, dispatch named types, and reconnect automatically. Its API is tiny:

Native EventSource
const source = new EventSource("/events");

source.onmessage = (event) => {
  render(JSON.parse(event.data));
};

source.addEventListener("message-created", (event) => {
  appendMessage(JSON.parse(event.data));
});

source.onerror = () => showConnectionState("reconnecting");

// Stop this subscription deliberately:
source.close();

The trade-off is control: native EventSource makes a GET request and does not let you attach arbitrary request headers or a POST body. Cookies work well for same-origin auth; short-lived URL tokens are possible but easy to leak. A fetch-based SSE client is the common choice when the request needs an authorization header, method, or JSON body.

POST a prompt, then parse the streamed response
const response = await fetch("/chat", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: "Bearer " + token,
  },
  body: JSON.stringify({ messages }),
});

const reader = response.body?.getReader();
// Decode bytes, buffer incomplete blocks, then parse complete SSE events.
A chunk is not an event
Transport chunks can split one event in half or contain several events. A fetch client must preserve the incomplete tail between reads and parse only complete blank-line-delimited blocks. Prefer a proven parser over a hand-rolled production implementation.
Resilience

Reconnect is built in; replay is not

If a native EventSource stream drops, the browser waits and reconnects. When events include id, it also sends the most recently seen value back as Last-Event-ID. That is enough for the server to know where the client stopped — but the server still needs retained history and replay logic.

Automatic reconnect + application replayLIVE
Live
Checkpoint
Disconnected
Retry wait
Reconnect
Replay
Live again
▱
Browsercursor: 42
id: 41
Live
event 41 arrived
open event stream
▤
Serverhistory: 41–44
41
received
42
pending
43
pending
44
pending
The browser owns the retry timer and remembers the last received ID.

Treat the event ID like a cursor, not a delivery guarantee. Reconnect boundaries can still produce duplicates, and a client may disappear before acknowledging what it rendered. Make event handling idempotent or deduplicate by ID when double-processing would hurt.

Production

Five sharp edges to design around

Recognize buffering by its shape

Your server can call write() correctly while nginx, a CDN, compression middleware, or another intermediary collects those writes. The format survives, but the experience stops being incremental.

Same server writes · different deliveryEVENTS ARRIVING
Streaming
proxy off
pass
1
rendered
Buffered
proxy on
1
0
rendered
Streaming: the UI makes progress after every write.
Buffered: the same events appear in one late burst.
Typical Node response headers
res.writeHead(200, {
  "Content-Type": "text/event-stream",
  "Cache-Control": "no-cache",
  Connection: "keep-alive",
  "X-Accel-Buffering": "no",
});

res.write("event: delta\ndata: " + JSON.stringify(payload) + "\n\n");
The modern use case

Why AI responses stream so naturally over SSE

A model produces output incrementally. The user sends one prompt, then mostly listens while the server produces text deltas. Waiting for the entire answer hides progress and inflates perceived latency; streaming lets the first useful words arrive immediately.

One answer · two streaming hopsMODEL → SERVER
✦
Model
▶"Server"
▤
App
▶
▱
UI
ASSISTANT MESSAGE
▋
0/8 deltas

The fit is unusually clean: client-initiated request, server-heavy response, text payloads, ordinary HTTP infrastructure, and an explicit completion event. The pieces delivered by the transport are not guaranteed to equal words or model tokens; the UI should simply append deltas in order.

One answer is often one stream
A chat UI commonly sends a POST for each user turn and closes that SSE response when the generated answer completes. That is different from a permanent notification subscription, even though both use the same wire format.
Trade-offs

When SSE is the right fit

Pick it when…

Choose something else when…

Key idea
Do not choose by product label. A messaging app can use REST for sends and SSE for incoming messages; a dashboard may poll; a collaboration canvas may need WebSockets. Direction, frequency, acceptable staleness, infrastructure, and scale choose the transport.
Practice

Check your understanding

Q1Multiple choice
An AI endpoint returns text/event-stream and calls write() for each delta, but the UI stays empty for eight seconds and then renders the whole answer. What should you investigate first?
Q2Multiple choice
A stream drops after event 42. Native EventSource reconnects with Last-Event-ID: 42. What else is required to resume at 43 without losing updates?
Q3Multiple choice
You must POST a chat history and attach an Authorization header while receiving an SSE response. Which client design matches the requirements?
Q4Sort each scenario
Choose the better starting point for each requirement.
Build logs and progress updates after one job submission
Dozens of collaborative cursors sending movement continuously
A continuous binary audio stream
A watchlist that receives server-driven price changes
Try it yourself

Design challenge

Design a deployment-status page that receives log lines, progress, a final result, and occasional errors. Write down:

  1. The named event types and the shape of each data payload.
  2. An event-ID strategy and how long the server retains replay history.
  3. Whether the browser uses EventSource or fetch, based on auth and request-body needs.
  4. The headers and proxy settings required for incremental delivery.
  5. What the UI does with duplicates, a reconnect, and an unrecoverable gap.
Key takeaways
  • →SSE is one long-lived HTTP response; the server writes events instead of the client repeatedly polling.
  • →The format is UTF-8 text with data, event, id, and retry fields; a blank line dispatches an event.
  • →Native EventSource parses, dispatches, and reconnects, but GET/header limits often push production apps toward fetch-based clients.
  • →Last-Event-ID supplies a resume cursor; reliable replay remains a server-side application contract.
  • →Plan for auth, connection limits, proxy buffering, backend concurrency, and SSE's one-way/text-only boundary.
  • →AI streaming is a strong fit because one client command produces an incremental, server-driven text response.
← Previous
17. Real-time Updates — Client Pulling