I21 · Part 2 · Intermediate
Real-time UI
Polling, Server-Sent Events or a WebSocket: choose the smallest thing that works, then handle the connection dropping.
07On this page
Three ways to hear about a change
| Polling | Server-Sent Events | WebSocket | |
|---|---|---|---|
| Direction | client asks | server to client only | both ways |
| Transport | ordinary HTTP | one long HTTP response | its own protocol, upgraded from HTTP |
| Reconnects itself | n/a | yes | no |
| Data | anything | text | text or binary |
| Fits | dashboards, slow changes | notifications, feeds, progress, live status, LLM output | chat, multiplayer, collaborative editing, games |
Most "real-time" features are one-way: the server has news and the browser displays it. That is exactly what Server-Sent Events do, and it is far less machinery than a WebSocket. Reach for a socket when the client also sends a steady stream of messages and latency matters.
Start with polling
If a five or ten second delay is acceptable, refetching on an interval
is a single option on a query: refetchInterval: 10_000. It works
through every proxy and serverless platform, needs no new infrastructure, and
fails in obvious ways. Move to a stream when the delay is noticeable to the
user or the polling load becomes the cost.
Server-Sent Events
const source = new EventSource("/api/notifications", { withCredentials: true });
source.onmessage = (e) => console.log(JSON.parse(e.data)); // unnamed events
source.addEventListener("order", (e) => console.log(e.data)); // event: order
source.onerror = () => { /* the browser is already reconnecting */ };
source.close();
The wire format is plain text: a response with the type
text/event-stream, and each message a block of
field: value lines ended by a blank line.
id: 42
event: order
data: {"id": 7, "status": "shipped"}
: a comment line, often used as a heartbeat
Three things come free. The browser reconnects automatically; the server
can tune the wait with a retry: field in milliseconds. If a message
carried an id, the reconnect sends it back as a
Last-Event-ID header, so the server can replay what was missed.
And named events let one connection carry several kinds of message.
// app/api/notifications/route.ts — a Route Handler
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const timer = setInterval(() => {
controller.enqueue(encoder.encode("data: " + JSON.stringify({ at: Date.now() }) + "\n\n"));
}, 5000);
// clear the timer when the client disconnects
},
});
return new Response(stream, {
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
});
}
Its limits are worth knowing before you choose it:
- No custom headers.
EventSourcecannot set anAuthorizationheader, so authenticate with a cookie (withCredentials) or a short-lived token in the URL. - Six connections per domain over HTTP/1.1, across every tab. Over HTTP/2 the limit is negotiated and defaults to 100, so serve it over HTTP/2.
- Text only, and one direction. Send the client's messages with an ordinary
fetch. - Open connections cost something on serverless. A function platform caps how long a request may stay open, so a long-lived stream may need a different host, or a managed real-time service.
WebSocket
const ws = new WebSocket("wss://example.com/live");
ws.onopen = () => ws.send(JSON.stringify({ type: "join", room: "a" }));
ws.onmessage = (e) => handle(JSON.parse(e.data));
ws.onclose = (e) => console.log(e.code, e.reason, e.wasClean);
A WebSocket gives you nothing for free that SSE does. It does not reconnect, it
does not resume, and a connection that silently died (a laptop lid, a mobile
network handover) may not tell you for minutes. You write the reconnect logic,
and you send periodic pings to notice a dead connection. The API also has no
backpressure: if messages arrive faster than you process them, they
pile up in memory, so a firehose needs throttling or the newer
WebSocketStream. The server side usually cannot live in a
serverless function, so a socket typically means a separate long-running
service.
Reconnect with backoff, always
function reconnectDelay(attempt, base = 500, max = 30_000, random = Math.random) {
const ceiling = Math.min(max, base * 2 ** attempt);
return random() * ceiling; // "full jitter"
}
If a server restarts and ten thousand clients reconnect on the same fixed schedule, they arrive together and knock it over again. Exponential growth spreads the retries out and the jitter — a random fraction of the delay — stops them re-synchronising. Reset the attempt counter once a connection has actually stayed up, not the moment it opens.
The hook, and where the connection lives
function useEventStream(url, onEvent) {
const handler = useEffectEvent(onEvent); // always the latest, never a dependency
useEffect(() => {
const source = new EventSource(url);
source.onmessage = (e) => handler(JSON.parse(e.data));
return () => source.close(); // Strict Mode's remount closes the first one
}, [url]);
}
The cleanup is the whole point: without close(), every remount
opens another connection, and Strict Mode makes that visible in development
immediately. See effects in depth for
why the handler goes through useEffectEvent instead of the
dependency array.
Where the connection is opened matters as much as how. One socket per component means twenty rows open twenty sockets. Open it once — in a provider, or a module that exposes a subscription through useSyncExternalStore — and let components subscribe to the messages they care about.
Put incoming data where the rest of the data lives
useEventStream("/api/orders/stream", (event) => {
queryClient.setQueryData(["orders", event.id], (old) => ({ ...old, ...event }));
});
A pushed message is just a server-state update that arrived by a different
route. Writing it into the query cache
means every component reading that order updates, the initial fetch and the
live updates share one source of truth, and there is no second copy in
useState to drift. Alternatively, treat the message as a signal
— "orders changed" — and invalidate the query so it refetches.
What breaks in production
- The gap. Between the initial fetch and the stream opening, or across a reconnect, events are missed. Fix it with
Last-Event-IDreplay, or refetch on every reconnect. - Duplicates and order. Give events ids or sequence numbers and make handlers idempotent, so applying one twice changes nothing.
- Render storms. A hundred messages a second cannot be a hundred renders. Buffer into a ref and flush once per animation frame, or update through a transition, and virtualise long lists.
- Hidden tabs. Close or slow the connection on
visibilitychange; a background tab does not need live updates. - No status in the UI. Show "reconnecting…" and disable actions that need the connection, or users act on stale data without knowing.
Interview answer, one sentence
"Most real-time UI is one-way, so I start with polling, move to Server-Sent Events when I need push because the browser reconnects and resumes for me, and use a WebSocket only when the client streams messages too — in which case I own reconnection with jittered backoff, heartbeats and gap recovery, and I write the messages into the query cache rather than component state."
Opens in the editor: write it, run it, and check it against real tests.
All 66 for this level →Finished reading?
0 of 57 chapters read in this series