WSS
Web Specification Studio Home
On this page
BlogArchitecturePerformanceAPIsNetworkingPublished

Multi-Tab Synchronization: Consolidating Redundant WebSockets

Use BroadcastChannel and SharedWorker to multiplex multiple tabs through a single WebSocket connection and reduce backend load.

Summary: Users often open the same application in multiple tabs. Opening a new WebSocket (or EventSource) in each tab multiplies backend connection cost for identical data. Using BroadcastChannel with the Web Locks API, or a SharedWorker, lets you keep a single transport connection and distribute messages locally.

When estimating WebSocket capacity, teams usually plan around concurrent users. In production that math undercounts load. Users open a dashboard, middle-click several links, and leave the tabs open. If every tab creates its own connection, one person can easily hold five or six sockets that all receive the same broadcast data, subscribe to the same channels, and keep the same idle timeouts alive.

The browser already provides two native ways to collapse those connections into one: leader election with BroadcastChannel + Web Locks, or a SharedWorker that owns the socket.

Simple approach: BroadcastChannel + Web Locks

BroadcastChannel is a same-origin message bus between windows, tabs, iframes, and workers. Combined with the Web Locks API it supports a reliable leader-election pattern:

  • One tab (the leader) holds the WebSocket.
  • All other tabs (followers) receive data only through the channel.

Avoid hand-rolled heartbeat or timestamp election. Use navigator.locks.

const channel = new BroadcastChannel('app_state');

// Every tab listens
channel.onmessage = (event) => {
  processData(event.data);
};

// Only one tab will acquire the exclusive lock
navigator.locks.request('websocket_leader', { mode: 'exclusive' }, async () => {
  const ws = new WebSocket('wss://api.example.com/stream');

  ws.onmessage = (event) => {
    processData(event.data);          // local tab
    channel.postMessage(event.data);  // other tabs
  };

  ws.onclose = () => {
    // Reconnect with backoff if this tab is still the leader
  };

  // The lock is held for the lifetime of this promise.
  // The browser releases it automatically when the tab is closed or discarded.
  return new Promise(() => {});
});

When the leader tab closes, the browser releases the lock and the next waiting tab becomes leader and opens a new socket.

Important behaviors to handle:

  • There is a short gap between the old leader disappearing and the new leader connecting. Use sequence numbers or Last-Event-ID-style recovery if your protocol requires it.
  • If the leader tab is frozen (heavy CPU starvation) but not closed, the lock stays held and followers cannot take over. A heartbeat that voluntarily releases the lock under extreme event-loop delay is a useful safeguard.

Reliable approach: SharedWorker

Leader election still ties the socket to a tab’s lifetime. Moving the connection into a SharedWorker is usually cleaner.

A SharedWorker is a single background thread shared by all scripts from the same origin. Multiple tabs receive their own MessagePort to talk to that one worker.

Worker (shared-worker.js)

const ports = new Set();
let socket = null;
let reconnectTimer = null;

function connectSocket() {
  if (socket || ports.size === 0) return;

  socket = new WebSocket('wss://api.example.com/stream');

  socket.onmessage = (event) => {
    for (const port of ports) {
      try {
        port.postMessage(event.data);
      } catch {
        ports.delete(port); // port may already be closed
      }
    }
  };

  socket.onclose = () => {
    socket = null;
    if (ports.size > 0) {
      // Basic backoff; production code should add jitter and a maximum
      reconnectTimer = setTimeout(connectSocket, 2000);
    }
  };
}

self.onconnect = (event) => {
  const port = event.ports[0];
  ports.add(port);

  port.onmessage = (e) => {
    if (e.data?.type === 'DISCONNECT') {
      ports.delete(port);
      if (ports.size === 0) {
        clearTimeout(reconnectTimer);
        socket?.close();
        self.close(); // allow the worker to be collected
      }
    }
    // Optional: handle AUTH token updates here
  };

  port.onmessageerror = (err) => {
    console.error('Message deserialization failed', err);
  };

  port.start();
  connectSocket();
};

Page script

const worker = new SharedWorker('/shared-worker.js');

worker.port.onmessage = (event) => {
  processData(event.data);
};

worker.port.start();

// pagehide is more reliable than beforeunload on mobile
window.addEventListener('pagehide', () => {
  worker.port.postMessage({ type: 'DISCONNECT' });
});

All reconnection, backoff, and (if needed) token-refresh logic now lives in one place. UI code only reacts to messages.

Limitations

Safari / iOS
SharedWorker was unavailable for several years and returned in Safari 16 / iOS 16. If you must support iOS 15 or older, use the BroadcastChannel + Web Locks approach instead.

Same-origin only
Both BroadcastChannel and SharedWorker are restricted to the exact origin (scheme + host + port). They do not work across subdomains, different browser profiles, or normal ↔ private browsing contexts.

Authentication
Cookie-based auth works automatically because the worker shares the cookie jar. Header-based tokens (JWTs) must be sent from a tab to the worker with postMessage before the socket is opened. When a token expires, the worker can request a fresh token from a tab and reconnect.

Ordering
Neither BroadcastChannel nor MessagePort guarantees strict global ordering under heavy load. Include sequence numbers when order matters.

Debugging
Network activity from a SharedWorker does not appear in the normal page DevTools. Open chrome://inspect/#workers (or the equivalent in other browsers) and inspect the worker directly.

Late-joining tabs
A tab that connects after the socket is already open only receives subsequent messages. If the application needs current state, the worker (or the first message after connect) should provide a snapshot or the client should fetch the latest state separately.

Decision framework

  1. Do users commonly keep multiple tabs open?
    For B2B dashboards, trading tools, and most SaaS products the answer is yes — consolidate connections. For many consumer mobile web apps the answer is no.

  2. Do you need to support iOS 15 or older?
    Prefer BroadcastChannel + Web Locks. Otherwise SharedWorker is usually the cleaner design.

  3. Is message parsing expensive?
    A SharedWorker can parse or decompress once and hand structured data to every tab, reducing main-thread work.

Client-side multiplexing removes redundant load that a load balancer cannot distinguish from real traffic. It is one of the highest-leverage infrastructure savings available to frontend teams.

FAQ

Why is the SharedWorker still alive after every tab is closed?

Active sockets or timers can keep the worker alive. Always remove ports on pagehide, close the socket when the port set is empty, and call self.close().

Can I share a worker across subdomains?

No. The same-origin policy is strict. The usual workaround is a hidden iframe on a common origin that owns the SharedWorker; pages talk to the iframe with window.postMessage.

How should JWT refresh work inside a SharedWorker?

Have the worker post an AUTH_REQUIRED message. A tab refreshes the token and posts it back. The worker then reconnects the socket with the new token.

Does BroadcastChannel work between normal and Incognito windows?

No. Channels and workers are scoped to the browsing context group. Different profiles and private windows are isolated.

What happens to a Web Lock if the leader tab freezes?

The lock stays held. A heartbeat that detects a severely stalled event loop and voluntarily releases the lock is the practical mitigation.

How do I transfer large binary data efficiently?

Use transferable objects:

port.postMessage(buffer, [buffer]);

After transfer the buffer is neutered in the sending context.

Can a Service Worker replace a SharedWorker for this use case?

No. Service Workers are designed for request interception and short-lived work. Their lifecycle is aggressively managed by the browser. A long-lived socket belongs in a SharedWorker (or in a leader tab).

From the team at

We build digital products and explore the modern web standards behind them.

Related posts