Skip to the chapter
←ReactGGroundwork⚛React
React/Intermediate/I21
32 / 57 written chapters
/

The cover
  1. B1Setup & JSX
  2. B2Thinking in React
  3. B3Components
  4. B4Props
  5. B5useState
  6. B6Events & conditionals
  7. B7Lists & keys
  8. B8Forms
  9. B9useEffect
  10. B10Lifting state & styling
  11. B11Guided project
  12. ★Cheat page
  1. I1useRef
  2. I2Context
  3. I3useReducer
  4. I4Custom hooks
  5. I5Effects in depth
  6. I6Routing
  7. I7State architecture
  8. I8Memoisation
  9. I9Boundaries & portals
  10. I10Data fetching
  11. I11Forms at scale
  12. I12State libraries
  13. I13Auth
  14. I14TanStack Query
  15. I15Testing
  16. I16TypeScript
  17. I17Animation
  18. I18E2E testing
  19. I19i18n
  20. I20File uploads
  21. I21Real-time UI
  22. I22shadcn/ui
  23. ★Cheat page
  1. A1Fiber
  2. A2Concurrent
  3. A3Suspense
  4. A4Server Components
  5. A5Server Actions
  6. A6use()
  7. A7Rendering strategies
  8. A8Effect timing
  9. A9External stores
  10. A10Refs in depth
  11. A11Component patterns
  12. A12Performance
  13. A13Accessibility
  14. A14Design systems
  15. A15Advanced TypeScript
  16. A16Architecture at scale
  17. A17Custom renderers
  18. A18Migrating & upgrading
  19. A19Production debugging
  20. A20Review & judgement
  21. A21State machines
  22. A22Feature flags
  23. A23Interview bank
  24. ★Cheat page

⚛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.

5 min read7 sections2 exercises
32 of 57

In this chapter

  • Three ways to hear about a change
  • Server-Sent Events
  • WebSocket
  • Reconnect with backoff, always
  • The hook, and where the connection lives

and 2 more

about 5 min
07On this page
  1. Three ways to hear about a change
  2. Server-Sent Events
  3. WebSocket
  4. Reconnect with backoff, always
  5. The hook, and where the connection lives
  6. Put incoming data where the rest of the data lives
  7. What breaks in production

Three ways to hear about a change

PollingServer-Sent EventsWebSocket
Directionclient asksserver to client onlyboth ways
Transportordinary HTTPone long HTTP responseits own protocol, upgraded from HTTP
Reconnects itselfn/ayesno
Dataanythingtexttext or binary
Fitsdashboards, slow changesnotifications, feeds, progress, live status, LLM outputchat, 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. EventSource cannot set an Authorization header, 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-ID replay, 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."

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsParse a Server-Sent Events chunkintermediate
  • 5 testsSubscribe to a live feed, unsubscribe on unmountintermediate
All 66 for this level →
I21

Finished reading?

0 of 57 chapters read in this series

→Next stop

  1. I20Previous · I20File uploads
  2. I21you are here
  3. Next · I22shadcn/uiNot a component library you install, but components whose source you copy into your project and own.5 min read

01On this page

7 sections · 0%

  1. 01Three ways to hear about a change
  2. 02Server-Sent Events
  3. 03WebSocket
  4. 04Reconnect with backoff, always
  5. 05The hook, and where the connection lives
  6. 06Put incoming data where the rest of the data lives
  7. 07What breaks in production

[] chapters · t top