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.
-
ws_reconnect_rate -
ws_connection_state— current readyState as a gauge, tagged by route, to catch components that never reachopen -
ws_message_rate— inbound frames/sec; a sudden drop to zero whilestate=open -
ws_dispatch_latency_ms— time fromonmessage -
JSHeapUsedSizegrowth — sampleperformance.memory.usedJSHeapSize -
ws_active_subscribers -
ws_send_buffer_depth—ws.bufferedAmount
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 |
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 #
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.
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