Sharing one WebSocket across browser tabs #
Power users open your app in eight tabs. Each tab opens its own WebSocket, so one person costs the server eight connections, receives every notification eight times (and plays the sound eight times), and triggers eight reconnects at once when the network blips. Across your user base, a meaningful share of your connection count — and your fan-out cost — is duplicate tabs. The browser offers two ways to share one connection between tabs of the same origin: a SharedWorker that owns the socket and serves every tab, and BroadcastChannel with a leader election where one tab owns the socket and relays to the others. Either turns eight connections into one.
Root cause #
Each browsing context — tab, window, iframe — runs its own JavaScript with its own objects, so a WebSocket created in one tab is invisible to another. Without coordination, every tab that loads the app connects independently. The server cannot tell the connections belong to one person on one machine without extra bookkeeping, so it delivers everything to all of them.
The duplication costs more than connections. Reconnect storms scale with tabs rather than users, as covered in exponential backoff with jitter for WebSocket reconnects. Per-user rate limits trip because one user appears to send eight times the traffic. And user-visible side effects — desktop notifications, sounds, unread badges — fire once per tab.
Resolution #
Prefer a SharedWorker: one worker instance per origin is shared by every tab that connects to it, and it can own the WebSocket directly. Each tab talks to the worker over a MessagePort; the worker keeps one socket, tracks which tabs are subscribed to which channels, and relays messages. Where SharedWorker is unavailable (notably older mobile browsers), fall back to BroadcastChannel with a leader tab.
// shared-socket.worker.ts — one instance for all tabs of this origin
const WS_URL = 'wss://rt.example.com/ws';
const ports = new Set<MessagePort>();
const subsByPort = new Map<MessagePort, Set<string>>();
let ws: WebSocket | null = null;
function channelRefCount(channel: string) {
let n = 0;
for (const subs of subsByPort.values()) if (subs.has(channel)) n++;
return n;
}
function ensureSocket() {
if (ws && ws.readyState <= WebSocket.OPEN) return;
ws = new WebSocket(WS_URL);
ws.onopen = () => {
// Re-subscribe the union of every tab's channels after (re)connect.
const all = new Set([...subsByPort.values()].flatMap((s) => [...s]));
for (const ch of all) ws!.send(JSON.stringify({ op: 'sub', channel: ch }));
broadcast({ type: 'status', status: 'open' });
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
for (const [port, subs] of subsByPort) if (subs.has(msg.channel)) port.postMessage({ type: 'msg', msg });
};
ws.onclose = () => {
broadcast({ type: 'status', status: 'closed' });
if (ports.size > 0) setTimeout(ensureSocket, 1_000 + Math.random() * 4_000); // one jittered retry for all tabs
};
}
function broadcast(m: object) { for (const p of ports) p.postMessage(m); }
(self as unknown as SharedWorkerGlobalScope).onconnect = (e: MessageEvent) => {
const port = e.ports[0];
ports.add(port);
subsByPort.set(port, new Set());
ensureSocket();
port.onmessage = ({ data }) => {
const subs = subsByPort.get(port)!;
if (data.op === 'sub' && !subs.has(data.channel)) {
subs.add(data.channel);
if (channelRefCount(data.channel) === 1 && ws?.readyState === WebSocket.OPEN)
ws.send(JSON.stringify({ op: 'sub', channel: data.channel })); // first tab to want it
} else if (data.op === 'unsub' && subs.delete(data.channel)) {
if (channelRefCount(data.channel) === 0 && ws?.readyState === WebSocket.OPEN)
ws.send(JSON.stringify({ op: 'unsub', channel: data.channel })); // last tab left
} else if (data.op === 'send') {
ws?.send(JSON.stringify(data.payload));
} else if (data.op === 'bye') {
// Tab is closing: drop its subscriptions; close the socket if no tabs remain.
for (const ch of subs) { subs.delete(ch); if (channelRefCount(ch) === 0) ws?.send(JSON.stringify({ op: 'unsub', channel: ch })); }
subsByPort.delete(port); ports.delete(port);
if (ports.size === 0) ws?.close(1000, 'no tabs');
}
};
port.start();
};
// In each tab
const worker = new SharedWorker(new URL('./shared-socket.worker.ts', import.meta.url), { type: 'module', name: 'rt' });
worker.port.onmessage = ({ data }) => { if (data.type === 'msg') handle(data.msg); };
worker.port.start();
worker.port.postMessage({ op: 'sub', channel: 'notifications' });
addEventListener('pagehide', () => worker.port.postMessage({ op: 'bye' }));
declare function handle(msg: unknown): void;
Browsers do not notify a SharedWorker when a tab’s port goes away, which is why tabs send bye on pagehide. That event is not guaranteed on crashes, so also have each tab ping the worker every few seconds and let the worker drop ports that go quiet. User-visible side effects such as desktop notifications should be triggered once — from the worker via the Notifications API, or by the single tab that is currently visible — never once per tab.
BroadcastChannel fallback #
Without SharedWorker, elect one tab as leader using the Web Locks API (navigator.locks.request('rt-leader', …) holds a lock for as long as the tab lives, and the next tab acquires it automatically when the leader closes). The leader opens the WebSocket and republishes messages on a BroadcastChannel; follower tabs send their outbound messages and subscription changes to the leader over the same channel. The trade-off is failover: when the leader tab closes, a new leader must reconnect, which costs one reconnect rather than none.
Edge cases #
Private browsing and partitioning. SharedWorkers are shared per origin and per storage partition, so a tab in a private window or a third-party iframe gets a separate worker. That is correct behaviour; just do not assume a single worker per user.
Deploys. A SharedWorker keeps running old code as long as any tab holds it. After a deploy, new tabs connect to the old worker if its script URL is unchanged. Version the worker’s name or script URL per release, or have the worker announce its version so new tabs can detect a mismatch.
Background throttling. Workers are throttled less than hidden tabs, which makes the SharedWorker a good owner for heartbeats. The per-tab visibility concerns from WebSocket reconnect with the Page Visibility API largely disappear when no tab owns the socket.
Verification #
Open the app in four tabs and check the Network panel in each: only one WebSocket should exist, visible under the SharedWorker in chrome://inspect/#workers (or the Sources panel’s worker list). Trigger a notification and confirm it appears once. Close tabs one by one; the socket must stay open until the last closes, then close with code 1000. On the server, compare connections per user id before and after rollout — the average should drop towards one.
Operational checklist #
FAQ #
Can multiple tabs share one WebSocket connection? #
Not directly — each tab has its own JavaScript environment. A SharedWorker can own one socket and serve every tab, or one tab can own it and relay to the others through BroadcastChannel.
Why not use a service worker? #
Browsers terminate service workers when they are idle, often within seconds to minutes, which closes any WebSocket they hold. They are designed for request interception and push, not long-lived connections.
Does sharing work across different subdomains? #
No. SharedWorkers and BroadcastChannel are scoped to one origin. Tabs on app.example.com and admin.example.com each get their own shared socket.
What happens when the SharedWorker crashes? #
Tabs lose their port messages; detect it with a periodic ping to the worker and reconstruct the worker (constructing new SharedWorker again starts a new instance if the old one is gone), then re-send subscriptions.
Related #
- Sharing One WebSocket Across React Components — sharing within one tab.
- Parsing WebSocket Messages in a Web Worker — the dedicated-worker version.
- WebSocket Reconnect with the Page Visibility API — background behaviour without a worker.
- Queueing WebSocket Messages While Offline — outbound queues that can live in the worker.
Back to Real-Time Rendering Performance.