Frontend Real-Time State Hooks & UI Patterns #
This guide covers how to bind a live WebSocket to component-tree state without leaking listeners, double-connecting, or rendering on every frame. It is for frontend engineers wiring React hooks, Vue 3 composables, or Svelte stores to a real-time backend and debugging why the UI desyncs, stalls under Strict Mode, or grows the JS heap until the tab is killed. The patterns are framework-agnostic at the core: a single connection owner exposes a reactive snapshot, and the framework adapter subscribes to it. Get that ownership boundary right and reconnection, optimistic updates, and teardown all become local concerns instead of cross-cutting bugs.
Infrastructure baseline #
Before any hook works reliably, the build and transport layer must support persistent upgraded connections through dev and prod. These are the prerequisites that cause “works locally, breaks behind the proxy” reports.
Pin a recent toolchain. The hooks below assume React 18.2+ (so useSyncExternalStore and Strict Mode double-invoke semantics are available), Vue 3.4+, Svelte 4+, and TypeScript 5.x with "strict": true. Native WebSocket is assumed in the browser; do not add socket.io-client unless you are explicitly comparing it, because its framing changes every snapshot contract below.
The dev server proxy must forward the upgrade, or HMR and your socket will fight over the same port. For Vite:
// vite.config.ts — forward the WebSocket upgrade to the API in dev
import { defineConfig } from 'vite';
export default defineConfig({
server: {
proxy: {
'/ws': {
target: 'ws://localhost:8080', // backend ws origin
ws: true, // REQUIRED: proxy the Upgrade handshake
changeOrigin: true,
},
},
},
});
In production the same upgrade headers must survive the reverse proxy. A misconfigured proxy_read_timeout is the single most common cause of “the socket dies after 60 seconds of silence” — see Configuring Nginx for WebSocket Upgrades for the full block.
# nginx.conf — preserve the upgrade and keep idle sockets alive
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s; # do not cut idle real-time sockets
}
Use wss:// everywhere a page is served over HTTPS; a mixed-content ws:// connection is silently blocked by the browser and surfaces only as a failed handshake.
Core mechanism: the reactivity model #
The central pattern is a single connection owner that holds the WebSocket, runs a small state machine, and exposes an immutable snapshot. The framework adapter never touches the raw socket — it subscribes to the owner and re-renders when the snapshot identity changes. This is what keeps one logical connection from becoming three when a parent re-renders.
In React, the correct primitive is useSyncExternalStore, not useState inside useEffect. useState tears under concurrent rendering and re-subscribes on every effect re-run; useSyncExternalStore reads from an external source with a stable subscribe/getSnapshot contract and is concurrency-safe.
// useWebSocket.ts — React adapter over an external connection owner
import { useSyncExternalStore, useCallback } from 'react';
import { ConnectionManager, type WSSnapshot } from './connection-manager';
// One manager per URL, cached at module scope so re-renders never reconnect.
const managers = new Map<string, ConnectionManager>();
function getManager(url: string): ConnectionManager {
let m = managers.get(url);
if (!m) { m = new ConnectionManager(url); managers.set(url, m); }
return m;
}
export function useWebSocket(url: string): WSSnapshot & { send: (d: unknown) => void } {
const manager = getManager(url);
// subscribe: stable identity so React never resubscribes spuriously.
const subscribe = useCallback((cb: () => void) => manager.subscribe(cb), [manager]);
// getSnapshot MUST return a referentially-stable object when nothing changed,
// or React will loop "getSnapshot should be cached" warnings and re-render forever.
const snapshot = useSyncExternalStore(subscribe, () => manager.getSnapshot());
const send = useCallback((data: unknown) => manager.send(data), [manager]);
return { ...snapshot, send };
}
The owner itself is a deterministic state machine over the four WebSocket readyStates, plus a heartbeat and a reconnection hook. Reconnection logic is deliberately delegated to a shared backoff policy rather than reinvented per component; the canonical implementation lives in Auto-Reconnection Strategies.
// connection-manager.ts — the single owner; framework-agnostic
export type WSState = 'connecting' | 'open' | 'closing' | 'closed';
export interface WSSnapshot { state: WSState; lastMessage: unknown; error: string | null; }
const HEARTBEAT_INTERVAL_MS = 30_000;
export class ConnectionManager {
private ws: WebSocket | null = null;
private heartbeat: ReturnType<typeof setInterval> | null = null;
private listeners = new Set<() => void>();
// Cached snapshot: identity only changes when fields change (stable getSnapshot).
private snapshot: WSSnapshot = { state: 'closed', lastMessage: null, error: null };
constructor(private readonly url: string) { this.connect(); }
private connect(): void {
this.patch({ state: 'connecting', error: null });
this.ws = new WebSocket(this.url);
this.ws.onopen = () => { this.patch({ state: 'open' }); this.startHeartbeat(); };
this.ws.onmessage = (e) => this.patch({ lastMessage: this.parse(e.data) });
this.ws.onerror = () => this.patch({ error: 'transport error' });
this.ws.onclose = () => { this.stopHeartbeat(); this.patch({ state: 'closed' }); };
}
private parse(data: unknown): unknown {
try { return typeof data === 'string' ? JSON.parse(data) : data; }
catch { return data; } // tolerate non-JSON frames
}
private startHeartbeat(): void {
this.heartbeat = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send('{"type":"ping"}');
}, HEARTBEAT_INTERVAL_MS);
}
private stopHeartbeat(): void { if (this.heartbeat) clearInterval(this.heartbeat); }
// patch creates a NEW snapshot object so React/Svelte detect the change by identity.
private patch(p: Partial<WSSnapshot>): void {
this.snapshot = { ...this.snapshot, ...p };
this.listeners.forEach((cb) => cb());
}
getSnapshot(): WSSnapshot { return this.snapshot; }
subscribe(cb: () => void): () => void {
this.listeners.add(cb);
return () => { this.listeners.delete(cb); }; // teardown removes only this reader
}
send(data: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(data));
}
destroy(): void { this.stopHeartbeat(); this.ws?.close(1000, 'teardown'); this.listeners.clear(); }
}
The same owner powers a Vue ref (wrap getSnapshot in shallowRef and call triggerRef from the listener) and a Svelte readable store (the subscribe signature is already Svelte-store-compatible). One implementation, three adapters.
Scaling & architecture #
The hard part is not one connection — it is many components reading one connection across a deep tree without prop-drilling the socket or fanning out subscriptions per node. The rule: the socket is a singleton; selectors are per-component. Each component subscribes with a selector that returns only the slice it renders, so a chat-roster update does not re-render the message pane.
Three architectural decisions follow from this shape. First, place the manager at module scope or in a context provider mounted once near the root — never inside a component that remounts on route changes, or you reconnect on every navigation. Second, fan messages out through a typed dispatcher so each channel maps to a store slice; route-level code never parses raw frames. Third, when many components need overlapping slices, back the snapshot with a normalized store (Redux, Zustand, or Pinia) so updates are O(changed entities), not O(tree). See State Sync & Optimistic Updates for the merge-and-rollback layer that sits between the dispatcher and the store.
For large rosters or feeds, virtualize the rendered list. A 5,000-row presence list updated 10×/sec will pin the main thread regardless of how clean the subscription is; render only the visible window.
Observability checklist #
Client-side real-time bugs are invisible in server logs. Emit these named metrics from the browser (via performance, PerformanceObserver, and a beacon to your RUM endpoint) so churn and leaks are measurable before users report them.
Wire JSHeapUsedSize and ws_active_subscribers together — leaked subscribers and heap growth almost always move in lockstep, and the pair pinpoints the offending route.
Failure modes #
| Failure | Symptom | Root cause | Mitigation |
|---|---|---|---|
| Double connection | Two sockets per page; duplicate messages | React Strict Mode / re-render creates a new WebSocket in useEffect |
Own the socket at module scope; key managers by URL; idempotent connect() |
| Leaked listeners | JSHeapUsedSize grows each navigation; tab eventually crashes |
Effect adds onmessage but cleanup never removes it |
Return a disposer from subscribe; clear timers in destroy() |
| Render storm | UI janks at high message rate; INP spikes | Every frame triggers a top-level re-render | Per-component selectors + normalized store; virtualize long lists |
| Silent half-open | state stays open but no messages arrive |
TCP died; no heartbeat to detect it | Heartbeat ping + missed-pong timeout that forces reconnect |
| Stale closure | Handler reads an old value of state/props | Callback captured a value from a prior render | Read from the external store, not from closure; stable useCallback deps |
Rendering under a firehose #
A real-time backend that comfortably pushes 200 messages a second will comfortably destroy a React application that renders each one. The bottleneck is never the socket; it is the reconciliation work you trigger by treating every frame as a state update.
The arithmetic is unforgiving. At 60 frames per second the browser has 16.7 ms per frame for everything — layout, paint, your JavaScript. A dashboard with 40 subscribed components that re-renders all of them on every message has roughly 0.4 ms per component before it drops a frame, and that is before any of them does real work. The symptom is a page that feels heavy while the CPU graph looks unremarkable, because the cost is spread across thousands of tiny render passes rather than concentrated anywhere a profiler flags.
Three techniques, applied in this order, resolve almost every case.
Batch by frame, not by message. Accumulate incoming messages in a plain array and flush them into state once per animation frame. Twenty messages arriving in 16 ms become one state update and one render pass instead of twenty, and the user sees exactly the same thing because the screen could not have shown the intermediate states anyway.
Subscribe narrowly. A component that needs one entity should subscribe to that entity, not to the whole stream. Whether that is a selector in a store, a context split by key, or a small event emitter keyed by topic matters less than the property it buys: a message about entity A must not schedule a render for the ninety components watching entities B through Z.
Keep transient values out of React state entirely. A cursor position that updates 30 times a second should be written directly to a DOM node or a canvas, not through setState. React’s model is a poor fit for values that change faster than the user can perceive, and there is no penalty for stepping outside it for those specific cases while keeping everything durable in state.
There is a fourth technique worth knowing about because it is so often reached for first and helps least: memoisation. React.memo and useMemo prevent re-rendering when props are unchanged, which is valuable — but if your state update replaces the whole collection on every message, every child’s props are changed, and memoisation costs a comparison while saving nothing. Fix the update granularity first; memoise afterwards, where the profiler says it pays.
The measurement that guides all of this is a Performance recording taken during live traffic rather than a synthetic test. What you are looking for is not total CPU but the shape: a dense sawtooth of short scripting blocks means too many updates, a few long blocks mean expensive components, and long purple layout bars mean the DOM structure rather than the data rate is the problem. Each has a different fix, and guessing between them wastes days.
Testing the connection layer #
Real-time UIs have a reputation for being untestable, and it is undeserved. What makes them hard is not the transport but two specific properties: the interesting behaviour is time-dependent, and the code under test usually reaches for a global WebSocket it does not own. Remove both and the tests become as ordinary as any other.
The first move is dependency injection. A hook that calls new WebSocket() inline can only be tested against a real server; a hook that accepts a socket factory can be handed a controllable fake in one line. That single change is what makes every assertion below possible, which is why the hook shape in this area threads the factory through as an option.
The second is fake timers. Reconnect backoff, heartbeat intervals and acknowledgement timeouts are all defined in milliseconds, and a test that waits for them in wall-clock time is at the mercy of whatever else the CI runner is doing — which is exactly where “passes locally, fails one run in eight” comes from. Advancing a fake clock by a known amount makes the same assertion instant and deterministic.
With those two in place, the tests worth writing are a short and specific list: unmount during connect (the most common leak), clean close versus abnormal close (the two must produce opposite reconnect behaviour), a message arriving after teardown, a malformed payload that must be counted rather than thrown, backoff resetting after a successful reconnect, and two mounted consumers sharing a single socket. Each runs in single-digit milliseconds against a fake, and each corresponds directly to a bug that reaches production when it is missing.
End-to-end tests still earn their place, but a small number of them: one that proves the real proxy relays the upgrade, one that proves two browser contexts see each other’s changes, and one that proves the app recovers when the network genuinely disappears. Everything else is faster, more reliable and more precise one layer down. The fake socket, the fake-timer recipes and the Playwright patterns are covered in testing real-time frontends.
The three bugs every real-time UI ships once #
Across frameworks and codebases the same three defects recur, and each has a single-line cause that is obvious in hindsight.
The socket that survives navigation. Cleanup runs, the component unmounts, and a pending reconnect timer fires four seconds later into a dead component — opening a connection nobody owns and, in React’s development mode, doing it twice. The cause is that teardown closed the socket but did not clear the timer, and a timer holds its callback, which holds the entire captured scope. Every teardown must clear timers before closing sockets, and both must happen unconditionally rather than inside a status check.
The handler that reconnects after logout. The close handler retries on every close, including the clean 1000 that logging out produced, so the app reopens a socket with a dead token forever at the minimum backoff interval. The fix is to read event.code and branch: 1000 is terminal, 1006 is retryable, 1008 means stop and surface an error. Two lines, and the bug is impossible.
The state update that arrives out of order. A WebSocket delivers frames in order, but an await inside the message handler does not preserve that order across messages, so a slow decode of message seven can land after message eight and overwrite it with older data. The symptom is a value that flickers and settles wrong. Either keep the handler synchronous up to the point of applying state, or serialise the asynchronous work into a promise chain — and carry a per-entity version so a stale apply is rejected regardless.
None of the three is exotic, and all three are invisible in development, where navigation is fast, sessions are short and the network is perfect. They surface in production as “it gets slow after a while”, “it reconnects forever after I log out” and “the numbers flicker” — three reports that sound like different bugs and are, in fact, the same class of missing discipline around the connection’s lifecycle.
Explore this area #
- React WebSocket Custom Hooks — build a typed
useWebSocketwithuseSyncExternalStoreand Strict-Mode-safe lifecycle binding. - Memory Leak Prevention — find and kill orphaned listeners, timers, and managers that grow the JS heap.
- State Sync & Optimistic Updates — apply optimistic mutations and roll them back on NACK while keeping referential integrity.
- Vue 3 Composables for Real-Time — wrap the connection owner in a composition-API composable backed by
shallowRef. - Svelte Stores for Real-Time — expose the socket as a native readable store with auto-reconnect and zero adapter glue.
FAQ #
How do I stop a real-time dashboard from dropping frames? #
Batch state updates into one per animation frame rather than one per message, subscribe components to the specific entities they display, and keep values that change faster than the eye can follow — cursor positions, live counters — out of React state entirely. Memoisation helps only after those three are in place; applied first, it costs comparisons and saves nothing because your props changed anyway.
Why use useSyncExternalStore instead of useState in useEffect? #
useState inside useEffect re-subscribes on every effect re-run and tears under React 18 concurrent rendering, so the UI can read a snapshot that no longer matches the committed tree. useSyncExternalStore reads from a stable external source with a subscribe/getSnapshot contract that React guarantees is consistent across a render, which is exactly the shape a live socket needs.
How do I stop React Strict Mode from opening two connections? #
Strict Mode intentionally double-invokes effects in development to surface missing cleanup. Move socket ownership out of the component: cache one ConnectionManager per URL at module scope and make connect() idempotent. The component only subscribes and unsubscribes, so the double-invoke is harmless.
Does the same connection owner work for Vue and Svelte? #
Yes. The owner exposes getSnapshot and a subscribe(cb) that returns a disposer. React consumes it via useSyncExternalStore, Vue via a shallowRef plus triggerRef, and Svelte directly — the Svelte store contract is already subscribe(cb) => unsubscribe.
Where should reconnection logic live? #
In the connection owner, not the component, so every reader shares one backoff policy and one timer. Use exponential backoff with jitter from Auto-Reconnection Strategies rather than a fixed-interval retry that thundering-herds your backend after an outage.
What changes for Socket.IO vs raw ws? #
Socket.IO wraps frames in its own protocol (Engine.IO transport, packet types, ack callbacks), so the raw onmessage/JSON.parse contract above does not apply — you subscribe to named events instead. The ownership pattern is identical; only the parse and dispatch layer changes.
Should each component open its own WebSocket? #
No — a dashboard with nine widgets should hold one connection, not nine. Browsers cap concurrent connections per origin, servers pay per-socket memory and fan-out work for each one, and subscription state becomes ambiguous when the same user appears as several sessions. Keep the connection outside the component tree, reference-count subscribers so it opens on the first and closes shortly after the last, and expose status through useSyncExternalStore so concurrent rendering cannot tear.
What should the UI show while the socket is down? #
Something explicit and honest. A connection-status indicator with three states — live, reconnecting, offline — costs almost nothing and removes the single most common real-time support complaint, which is a user acting on data they believe is current. Queued user actions must look pending rather than delivered, and a reconnect that recovers missed messages should not flash the intermediate empty state on the way through.
Related #
- React WebSocket Custom Hooks — the React adapter for the connection owner described here.
- Memory Leak Prevention — teardown discipline that keeps
JSHeapUsedSizeflat across navigations. - Vue 3 Composables for Real-Time — the Vue composition-API binding for the same owner.
- Svelte Stores for Real-Time — the Svelte-native store adapter with auto-reconnect.
- Auto-Reconnection Strategies — shared backoff policy the client owner should reuse.
Back to Real-Time WebSocket Engineering