Sharing One WebSocket Across React Components #
The dashboard renders nine widgets. Each one calls useWebSocket('wss://api.example.com/stream'), so the browser opens nine connections to the same host — and because Chrome caps concurrent connections per origin, the tenth widget silently never connects. On the server, one user now costs nine sockets, nine subscriptions and nine copies of every broadcast.
The fix is not a bigger connection limit. It is recognising that the connection is a shared resource the components subscribe to, not a per-component effect.
Root cause #
A hook that calls new WebSocket() inside useEffect creates one connection per calling component, because that is exactly what an effect does — it runs per component instance. Nothing about the hook signature suggests otherwise, which is why this pattern is so common: the hook that was written for one widget gets reused, and the cost scales with the UI rather than with the user.
Three consequences follow. Browsers limit concurrent connections per origin (typically around six for HTTP/1.1-style limits, and WebSockets count), so past a threshold connections queue or fail outright. Servers multiply their per-connection memory and their fan-out work by the widget count. And subscription state becomes ambiguous: if two components subscribe to the same room on two different sockets, a server that deduplicates by user sees a conflict, and one that does not sends every message twice.
The naive fix — a module-level singleton socket — trades one bug for another. It never closes, so navigating away from the real-time section leaves a connection open forever, and in development it survives hot module reloads, accumulating listeners on every save.
What you actually want is a reference-counted connection: opened on the first subscriber, shared by all of them, and closed a short delay after the last one leaves.
Resolution #
Keep the connection outside React entirely, and let components subscribe to it with useSyncExternalStore — which is designed for exactly this and is safe against concurrent rendering in a way that useState plus an effect is not.
// realtime-client.ts — one connection per URL, reference counted, React-free.
const LINGER_MS = 5_000; // keep the socket briefly after the last unsubscribe
const BASE_BACKOFF_MS = 1_000;
const MAX_BACKOFF_MS = 30_000;
type Handler = (msg: unknown) => void;
class SharedConnection {
private socket: WebSocket | null = null;
private refCount = 0;
private handlers = new Set<Handler>();
private lingerTimer: ReturnType<typeof setTimeout> | null = null;
private retryTimer: ReturnType<typeof setTimeout> | null = null;
private attempt = 0;
private statusListeners = new Set<() => void>();
status: 'idle' | 'connecting' | 'open' | 'reconnecting' = 'idle';
constructor(private url: string) {}
subscribe(handler: Handler): () => void {
this.handlers.add(handler);
this.refCount += 1;
if (this.lingerTimer) { clearTimeout(this.lingerTimer); this.lingerTimer = null; }
if (!this.socket) this.open();
return () => {
this.handlers.delete(handler);
this.refCount -= 1;
// Do NOT close immediately: a route change unmounts and remounts within
// the same tick, and closing here would rebuild the connection every time.
if (this.refCount === 0) {
this.lingerTimer = setTimeout(() => this.close(), LINGER_MS);
}
};
}
onStatusChange(fn: () => void): () => void {
this.statusListeners.add(fn);
return () => this.statusListeners.delete(fn);
}
send(payload: unknown): boolean {
if (this.socket?.readyState !== WebSocket.OPEN) return false;
this.socket.send(JSON.stringify(payload));
return true;
}
private setStatus(next: typeof this.status) {
this.status = next;
this.statusListeners.forEach((fn) => fn());
}
private open() {
this.setStatus(this.attempt === 0 ? 'connecting' : 'reconnecting');
const socket = new WebSocket(this.url);
this.socket = socket;
socket.onopen = () => { this.attempt = 0; this.setStatus('open'); };
socket.onmessage = (ev) => {
let msg: unknown;
try { msg = JSON.parse(ev.data as string); } catch { return; }
// Copy before iterating: a handler may unsubscribe during dispatch.
[...this.handlers].forEach((h) => h(msg));
};
socket.onclose = (ev) => {
this.socket = null;
if (ev.code === 1000 || this.refCount === 0) { this.setStatus('idle'); return; }
const ceiling = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** this.attempt++);
this.retryTimer = setTimeout(() => this.open(), Math.random() * ceiling);
this.setStatus('reconnecting');
};
}
private close() {
if (this.retryTimer) clearTimeout(this.retryTimer);
this.retryTimer = null;
this.socket?.close(1000, 'no subscribers');
this.socket = null;
this.attempt = 0;
this.setStatus('idle');
}
}
const pool = new Map<string, SharedConnection>();
export function connectionFor(url: string): SharedConnection {
let conn = pool.get(url);
if (!conn) { conn = new SharedConnection(url); pool.set(url, conn); }
return conn;
}
The React side is then tiny, and every component gets the same socket:
import { useCallback, useEffect, useSyncExternalStore } from 'react';
import { connectionFor } from './realtime-client';
export function useSharedSocket(url: string, onMessage?: (m: unknown) => void) {
const conn = connectionFor(url);
// useSyncExternalStore is the correct primitive for an external mutable source:
// it cannot tear under concurrent rendering the way useState + useEffect can.
const status = useSyncExternalStore(
useCallback((cb) => conn.onStatusChange(cb), [conn]),
() => conn.status,
() => 'idle' as const, // server snapshot for SSR
);
useEffect(() => {
if (!onMessage) return;
return conn.subscribe(onMessage); // returns the unsubscribe, which decrements
}, [conn, onMessage]);
return { status, send: (p: unknown) => conn.send(p) };
}
One caution: onMessage must be stable, or the effect re-subscribes on every render and the reference count churns. Wrap it in useCallback at the call site, or accept a ref-held handler inside the hook.
Verification #
Count sockets, not renders.
# In the browser console: how many WebSockets does this page actually hold?
performance.getEntriesByType('resource').filter(e => e.name.startsWith('ws')).length
In DevTools, the Network panel filtered to WS should show exactly one entry per distinct URL no matter how many widgets are mounted. Then exercise the lifecycle: navigate away from the real-time section and confirm the socket closes after the linger window with code 1000, navigate back within the window and confirm no new socket is created. Server-side, the connection count per user should be one — a fleet where it tracks widget count is the symptom this page exists to remove.
Operational checklist #
FAQ #
Should I use a context provider instead of a module-level pool? #
Use a provider when your entire real-time area sits under one subtree — it makes the connection’s lifetime explicit and testable, and it disposes naturally on unmount. Use the pool when real-time consumers are scattered across routes that do not share an ancestor, or when you need connections keyed by URL. The two compose: a provider that hands down a pooled connection gets both properties.
How many WebSockets can a browser hold? #
More than HTTP, but not unlimited — the practical limit is around 200 per browser and 30 or so per origin in current Chrome, with other browsers in a similar range. That is far above what a well-built app needs, which is the point: if you are anywhere near the limit, the architecture is opening connections per component rather than per user.
Does this break with React 18 StrictMode? #
No, and that is a good reason for the linger window. StrictMode runs mount → cleanup → mount in development, so the reference count goes 1 → 0 → 1 within a tick. Without the grace period the socket would close and immediately reopen on every mount; with it, the second mount cancels the pending close. The same mechanism makes the pattern robust against ordinary route transitions.
How do multiple components filter for the messages they care about? #
Subscribe once and dispatch by message type at the edge: each component’s handler ignores what is not addressed to it, or you layer a small typed event emitter over the connection so components subscribe to room:42 rather than to everything. Keep the filtering out of React state — re-rendering nine widgets for a message one of them wants is the other half of this performance problem.
What about tabs — should they share too? #
They cannot share a socket directly, but they can share a leader: one tab holds the connection and relays to the others over BroadcastChannel, which is the pattern covered in preventing memory leaks in React useEffect WebSockets for the tab-proliferation case. It is worth the complexity only when users routinely keep many tabs open on the same app.
Related #
- React WebSocket Custom Hooks — the single-connection hook this generalises.
- useWebSocket Cleanup and Teardown Patterns — the teardown rules the reference count must respect.
- Preventing Memory Leaks in React useEffect WebSockets — what happens when subscribers leak instead of unsubscribing.
- Fixing Zustand Stale WebSocket Subscriptions — the store-side version of the same stale-closure problem.
- Testing Real-Time Frontends — asserting that two mounted consumers really do share one socket.
Back to React WebSocket Custom Hooks