Real-Time Updates
Chapter 1 left a loose thread: HTTP is request-initiated, so the server can't just decide to push data to the browser whenever it feels like it. If a note gets updated by one browser tab, how does a second tab find out without the person sitting there hitting refresh? This chapter covers the two practical answers — polling and Server-Sent Events — and wires the second one into the store from Chapter 8.
The problem, again #
A plain request/response cycle ends the moment the response is sent. The connection closes. If something changes on the server thirty seconds later, there's no channel left to tell the browser about it — the browser would have to open a brand new request to find out anything. Real applications want the opposite: a note edited on your phone should show up on your laptop's already-open tab without anyone reloading it.
Option one: polling #
Polling sidesteps the problem by never actually needing the server to push anything: the browser just asks again, on a timer.
the simplest possible polling loopsetInterval(async () => {
const res = await fetch('/api/notes');
const notes = await res.json();
store.setNotes(notes);
}, 5000);
It's simple, it works everywhere, and for low-stakes data it's often good enough. The downsides show up as the app grows: most of those requests return "nothing changed," you're burning bandwidth and server load for updates that aren't happening, and there's an inherent lag equal to your polling interval — a five-second poll means a five-second-average delay before anyone sees a change.
Option two: Server-Sent Events #
Server-Sent Events (SSE) flip the model: instead of many short requests, the browser opens one long-lived request and the server keeps it open, writing new data into it as events happen. It's a one-way channel — server to browser only — which turns out to be exactly what most apps need: the browser already has a perfectly good way to talk to the server (regular HTTP requests for actions), it just needed a way to be told about changes.
The browser side is a built-in API called EventSource (see MDN's Server-Sent Events guide). You give it a URL, and it manages the connection — including automatically reconnecting if it drops:
the browser sideconst events = new EventSource('/api/live');
events.addEventListener('note-updated', (event) => {
const note = JSON.parse(event.data);
store.mergeNote(note);
});
On the server, the response is never "finished" — the handler holds the connection open and
writes to it whenever there's something to say, using a specific text format: an event:
line naming the event, a data: line with the payload, and a blank line to terminate it.
event: note-updated
data: {"id": 17, "title": "Groceries", "body": "Milk, eggs, bread"}
A small event bus #
The server needs to track every browser that currently has a live connection open, so it knows who to write to when something changes. A small class handles this: it's an event bus using a publish/subscribe pattern — route handlers publish events without knowing or caring who's listening.
Two small pieces of syntax in the class below are worth naming before you read past them.
#clients is a private class field — the # prefix means
it's only accessible from inside the class itself, not from outside code, unlike a plain property. And
reply.raw reaches past Fastify's own reply wrapper to the underlying Node.js
response object, because streaming raw SSE chunks byte-by-byte is lower-level than the JSON-in,
JSON-out shape Fastify's normal reply API is built for.
class LiveBus {
#clients = new Set();
register(reply) {
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
this.#clients.add(reply.raw);
reply.raw.on('close', () => this.#clients.delete(reply.raw));
}
publish(eventName, payload) {
const chunk = `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`;
for (const client of this.#clients) {
client.write(chunk);
}
}
}
wiring it into the route that opens the stream, and the one that mutates data
app.get('/api/live', async (req, reply) => {
app.services.liveBus.register(reply);
});
app.patch('/api/notes/:id', async (req, reply) => {
const updated = updateNote(req.params.id, req.body);
app.services.liveBus.publish('note-updated', updated);
return updated;
});
Notice the route that handles the actual edit doesn't know or care whether zero, one, or ten
browsers are currently listening on /api/live. It just publishes. That's the value of
the bus sitting between them — it's the same "decorate shared services onto the app instance"
pattern from Chapter 9, applied to a bus instead of a
database.
register() above accepts any request and publish() broadcasts to
every connected client, with no authentication and no per-user scoping. That's fine for a single
shared todo or notes list where every user is meant to see every update, but it is not safe to ship
as-is for anything with private data: a single #clients set has no way to know that Tab
A belongs to a different logged-in user than Tab B. A production version needs to authenticate the
/api/live connection like any other route, and either maintain a set of clients per user
(or per resource) instead of one global set, or filter each publish() so it only reaches
clients authorized to see that particular event.
Two more gaps worth knowing about, even though this chapter doesn't build solutions for them: a
slow or stalled client's write() calls can back up without any limit here, and there's no
heartbeat keeping idle connections alive through proxies that time out quiet connections after a
minute or two. Both are solvable (backpressure limits, a periodic comment-line ping), just outside
this chapter's scope.
Connecting SSE to the store #
On its own, an incoming SSE event is just a parsed JSON payload sitting in an event handler. The piece that makes it useful is the same store from Chapter 8: the SSE handler doesn't touch the DOM directly, it calls a store action, and every component reading from that store re-renders automatically because the state is reactive.
wiring the stream into the store once, at app startupconst events = new EventSource('/api/live');
events.addEventListener('note-updated', (event) => {
store.mergeNote(JSON.parse(event.data));
});
events.addEventListener('note-deleted', (event) => {
store.removeNote(JSON.parse(event.data).id);
});
Every component on screen that renders notes is already subscribed to the store's state. It never needed to know that an SSE connection exists at all — from its point of view, the notes list just changed, the same as if the user had edited it locally.
SSE is the transport. The store is still the single source of truth. Keeping that separation — "how data arrives" versus "where data lives" — means you could swap SSE for WebSockets, or even polling, later without touching a single component.
A word on WebSockets #
You'll hear WebSockets mentioned alongside SSE constantly. The difference is direction: a WebSocket is a full two-way channel — the browser can send data back over the same connection, not just receive it. That's essential for things like a live chat app where both sides are constantly sending. But it's also more machinery than you need for "tell the browser when something changed," which is strictly one-way. SSE is plain HTTP, works through ordinary proxies, and reconnects on its own. Reach for WebSockets when the browser genuinely needs to push a continuous stream back; otherwise SSE is the simpler tool for the job.
Open your browser's dev tools on any site you suspect uses live updates (a chat app, a stock
ticker) and check the Network tab for a request that never finishes loading, with a type of
eventsource. That's this chapter's mechanism, live in production.
Real-time updates handle the "something changed, tell everyone" case. The next chapter covers the opposite direction: work the server needs to do on its own schedule, with nobody watching.