Command Palette

Search for a command to run...

Hectal
PHASE 24Advanced ~28 min· topic 6 of 10

Topic 24.6

Real-Time Updates

In one line

Live data reaches a React app in four ways: polling (ask on a timer), long polling (the server holds the request until something changes), Server-Sent Events (a one-way stream from the server) and WebSockets (a two-way connection); whichever you pick, the client must reconnect with backoff, merge updates into state without duplicates, and show presence and typing indicators that expire.

Think of it like this

Waiting for a parcel. You can phone the shop every ten minutes and ask "is it here yet?" (polling). You can phone once and the shopkeeper stays on the line until it arrives, then you hang up and call again (long polling). You can sign up for text messages that the shop sends you whenever something changes (Server-Sent Events: they talk, you listen). Or you can both keep a walkie-talkie switched on and talk any time in either direction (WebSockets).

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Polling
Asking the server for news on a timer, whether or not anything changed.
Long polling
A request the server holds open until it has news, then the client immediately asks again.
Server-Sent Events (SSE)
A one-way stream of text events from the server over one open HTTP response (EventSource).
WebSocket
A persistent two-way connection where client and server can both send messages at any time.
Exponential backoff
Waiting longer after each failed retry (100, 200, 400 ms…), up to a limit.
Jitter
A small random amount added to retry delays so many clients don't retry at the same moment.
Presence
Showing who is online or viewing the same thing right now.
Idempotent merge
Applying an update in a way that gives the same result if it arrives twice.

Step by step

01Pick the transport

How fresh must data be, which direction does it flow, and what can your servers run? Start with the simplest option that meets the need.

Pick the transportdiagram
Rendering diagram…

02Own the connection in one effect

Open in the effect, subscribe to events, and close in the cleanup. Keep the socket in a variable inside the effect (or a ref), never in state.

useSocket.jswhole filejavascript
useEffect(() => {
  const ws = new WebSocket("wss://chat.example.com/rooms/42");
  ws.addEventListener("message", (e) => onMessage(JSON.parse(e.data)));
  return () => ws.close();
}, [roomId]);

03Reconnect with backoff

On close, schedule a reconnect after min(base × 2^attempt, cap) plus jitter. Reset attempt on open. In cleanup set a stopped flag so a deliberate close doesn't trigger a retry.

Reconnect with backoffdiagram
Rendering diagram…

04Merge, don't append blindly

Use the updater form of setState, skip ids you already have, keep order by id or sequence, and after a reconnect ask the server for what you missed (SSE does this with Last-Event-ID).

05Expire soft state

Typing and presence pings refresh a timer. When the timer runs out, remove the indicator. The server does the same for presence with heartbeats.

Try it yourself

  1. 1

    Poll faster

    Change the polling interval to 50ms. Predict the request count. Faster polling shows changes sooner but multiplies server load for every user.

  2. 2

    Remove de-duplication

    In the socket example, replace the merge with setMessages((old) => [...old, msg]). Predict the last line, and the warning React prints about keys (two items now share key={2}).

  3. 3

    Make the connection fail several times

    Make every connection before the fourth drop immediately (if (n < 4)). Predict the retry delays in the log: 100, 200, 400ms, then connected.

Code & diagrams

Polling vs long polling New tab

The score changes once. Polling made 5 requests to see it (and showed it up to 200ms late); long polling made 3: the first answered at once, the second waited for the change, the third is waiting for the next one.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

After 0.9 seconds the score is 1-0
Polling requests: 5, long-polling requests: 3
A fake WebSocket: reconnect with backoff and merge without duplicates New tab

`FakeSocket` behaves like a WebSocket (an `EventTarget` with open, message and close). The first connection drops; the hook waits 100ms and reconnects. The server resends message 2, and the merge skips it, so each message shows once, in order.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

connected (#1)
connection lost, retrying in 100ms
connected (#2)
Messages: Hi! | Anyone here? | Back online
Presence and a typing indicator that expires New tab

Asha sends three "typing" pings 100ms apart. Each one resets a 400ms timer kept in a ref; when they stop, the indicator disappears by itself, with no "stopped typing" message needed. Ben leaves later.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Online: nobody
Online: Asha
Online: Asha, Ben
Online: Asha, Ben | Asha is typing…
Online: Asha, Ben
Online: Asha
Server-Sent Events in React (not live: needs a server)jsx
import { useEffect, useState } from "react";

export function LiveScore({ matchId }) {
  const [score, setScore] = useState(null);
  useEffect(() => {
    const source = new EventSource("/api/matches/" + matchId + "/events");
    // The server writes:  event: score\nid: 17\ndata: {"home":1,"away":0}\n\n
    source.addEventListener("score", (e) => setScore(JSON.parse(e.data)));
    source.onerror = () => {
      // EventSource reconnects by itself and sends Last-Event-ID: 17.
      // readyState CLOSED (2) means it gave up (e.g. a 4xx/5xx response).
    };
    return () => source.close();
  }, [matchId]);
  return <p>{score ? score.home + " - " + score.away : "Waiting for kick-off"}</p>;
}
Pushing socket updates into the TanStack Query cache (not live)jsx
const queryClient = useQueryClient();
useEffect(() => {
  const ws = new WebSocket("wss://api.example.com/orders");
  ws.addEventListener("message", (e) => {
    const update = JSON.parse(e.data); // { id, status, version }
    queryClient.setQueryData(["order", update.id], (old) =>
      !old || update.version > old.version ? { ...old, ...update } : old, // ignore stale versions
    );
    queryClient.invalidateQueries({ queryKey: ["orders"] }); // lists refetch in the background
  });
  return () => ws.close();
}, [queryClient]);

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

No cleanup for the socket

In useLiveMessages, remove the effect's return () => { ... } cleanup and wrap the app in <StrictMode>.

terminal
$ Console
── what you'll see ──
connected (#1)
connected (#2)
(two live connections, and every message is handled twice)

Break #2

Reconnecting immediately in a loop

On close, call connect() right away with no delay, and let the server reject every connection.

terminal
$ Network panel
── what you'll see ──
Hundreds of WebSocket connection attempts per second; the browser tab and the server both slow down.

Myth vs fact

Myth

WebSockets are always the best choice for live data.

Fact

For one-way updates (notifications, scores, progress) SSE is simpler and reconnects by itself; for data that changes every minute, polling is cheaper to run. Choose by direction, freshness and infrastructure.

Myth

Once connected, messages arrive exactly once and in order.

Fact

Within one WebSocket connection, order is kept, but reconnects, retries and multiple sources cause duplicates, gaps and reordering. Merge by id and version.

Myth

You can rely on a "user stopped typing" event.

Fact

Tabs close and connections drop without sending it. Treat typing and presence as expiring signals.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    EventSource can't send custom headers (such as Authorization); it sends cookies for same-origin requests (and cross-origin with withCredentials: true). For token auth, use cookies, a short-lived token in the URL, or a fetch-based SSE client that reads response.body as a stream.

  • ▸

    Browsers limit HTTP/1.1 to about six connections per host, and each SSE stream holds one. Over HTTP/2 or HTTP/3 streams share one connection, so many tabs with SSE are fine; on HTTP/1.1 they can starve other requests.

  • ▸

    Keeping one socket per app (not per component) and exposing it as an external store read with useSyncExternalStore (Topic 10.3) avoids duplicate connections and lets any component subscribe to a slice. Libraries like Zustand (Phase 15) are often used as that store.

  • ▸

    Detect half-open connections (the network died silently, no close event) with heartbeats: the server pings every ~30 s and the client reconnects if it hears nothing for longer. Mobile browsers also suspend background tabs, so reconnect and resync on visibilitychange.

Remember this

  1. 1

    Polling is a normal request repeated on an interval (setInterval in an effect, or TanStack Query's refetchInterval). It works everywhere, through every proxy, and needs nothing special on the server. The costs: most requests return "nothing changed", and an update waits up to one full interval. Pause it when the tab is hidden (document.visibilityState) and back off when nothing changes. Good for dashboards that refresh every 30 seconds, job status, or as a fallback.

  2. 2

    Long polling sends a request that the server keeps open until there is news (or a timeout of ~30 s), answers it, and the client immediately sends the next one. Updates arrive almost instantly with ordinary HTTP. The cost is one held connection per client on the server and a reconnect per message. It was the standard before WebSockets and is still used as a fallback.

  3. 3

    Server-Sent Events (SSE) keep one HTTP response open and the server writes data: ... lines to it whenever something happens. The browser API is new EventSource(url); it reconnects automatically and resends the last event id (Last-Event-ID header) so the server can catch you up. It's one-way (server to client; you send with normal requests), text only, and works over HTTP/2 without the old six-connections-per-domain limit. Great for notifications, live scores, progress and streaming AI responses.

  4. 4

    WebSockets upgrade an HTTP connection into a persistent two-way channel (new WebSocket("wss://..."), events open, message, close, error, method send). Lowest latency and both sides can talk at any time: chat, multiplayer editing, games, live cursors. The costs: stateful servers that must scale and load-balance long-lived connections, proxies and firewalls that sometimes block them, and no automatic reconnection: you write it (or use a library such as Socket.IO or partysocket).

  5. 5

    Reconnect with exponential backoff. Connections drop: Wi-Fi changes, laptops sleep, servers deploy. On close, wait and reconnect; double the wait each failure (100ms, 200ms, 400ms…) up to a cap, add random jitter (so a server restart isn't followed by every client reconnecting at the same millisecond), reset the counter once connected, and reconnect immediately when the browser fires online. In React, own the connection in one effect (or one module-level store read with useSyncExternalStore, Topic 10.3) whose cleanup closes the socket and clears the retry timer; StrictMode's mount-unmount-mount in development (Topic 7.4) will prove the cleanup works.

  6. 6

    Merging live updates into state. Messages can arrive twice (resent after a reconnect), out of order, or before the initial page of data loaded. Give every update an id (and ideally a sequence number or version), de-duplicate by id, sort or insert by sequence, and ignore updates older than what you have. If you cache server data with TanStack Query, write updates into the cache (queryClient.setQueryData) or invalidate the query so it refetches. Presence (who is online) and typing indicators are soft state: a "typing" event means "still typing", so expire it after a few seconds without another event, rather than trusting a "stopped typing" event that may never arrive.

Explain it without notes

01

Compare polling, long polling, SSE and WebSockets.

02

How would you implement reliable reconnection for a WebSocket in React?

03

How do you merge live updates into existing state safely?

04

How would you implement a typing indicator?

Practice

01

Write a usePolling(fn, ms) hook that calls fn immediately and then every ms, skips calls while the tab is hidden, and stops on unmount.

02

Write the backoff delay function with a 30-second cap and full jitter.

Trade-offs

  • ↔

    Polling is simple and stateless but wasteful and laggy; push transports (SSE, WebSockets) are instant but need long-lived connections, reconnection logic and servers that can hold many open connections.

  • ↔

    SSE is simpler than WebSockets and reconnects by itself, but is one-way and text-only; WebSockets are two-way and binary-capable but need more code and infrastructure.

Done when you can

  • I can choose between polling, long polling, SSE and WebSockets for a feature.

  • I can own a connection in an effect with correct cleanup.

  • I can reconnect with exponential backoff, a cap and jitter.

  • I can merge live updates without duplicates or stale versions.

  • I can build presence and typing indicators that expire.