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.
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.
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.
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
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
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 sharekey={2}). - 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
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
Console
Nothing logged yet.Expected console output
After 0.9 seconds the score is 1-0
Polling requests: 5, long-polling requests: 3`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
Console
Nothing logged yet.Expected console output
connected (#1)
connection lost, retrying in 100ms
connected (#2)
Messages: Hi! | Anyone here? | Back onlineAsha 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
Console
Nothing logged yet.Expected console output
Online: nobody
Online: Asha
Online: Asha, Ben
Online: Asha, Ben | Asha is typing…
Online: Asha, Ben
Online: Ashaimport { 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>;
}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>.
Break #2
Reconnecting immediately in a loop
On close, call connect() right away with no delay, and let the server reject every connection.
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.
- ▸
EventSourcecan't send custom headers (such asAuthorization); it sends cookies for same-origin requests (and cross-origin withwithCredentials: true). For token auth, use cookies, a short-lived token in the URL, or a fetch-based SSE client that readsresponse.bodyas 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
closeevent) 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 onvisibilitychange.
Remember this
- 1
Polling is a normal request repeated on an interval (
setIntervalin an effect, or TanStack Query'srefetchInterval). 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
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
Server-Sent Events (SSE) keep one HTTP response open and the server writes
data: ...lines to it whenever something happens. The browser API isnew EventSource(url); it reconnects automatically and resends the last event id (Last-Event-IDheader) 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
WebSockets upgrade an HTTP connection into a persistent two-way channel (
new WebSocket("wss://..."), eventsopen,message,close,error, methodsend). 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
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 firesonline. In React, own the connection in one effect (or one module-level store read withuseSyncExternalStore, 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
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
Compare polling, long polling, SSE and WebSockets.
How would you implement reliable reconnection for a WebSocket in React?
How do you merge live updates into existing state safely?
How would you implement a typing indicator?
Practice
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.
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.