PAUL CHONGSenior Software Engineer

Multi-Tab Coordination

2026-08-08 · 14 min read

When a user has your app open in multiple tabs, each tab is an isolated JavaScript environment with its own memory, its own event loop, and its own network connections. Without coordination, tabs diverge: logging out in one tab leaves others authenticated, adding an item to a cart in one tab doesn't update the count in others, and five tabs each open their own WebSocket to the same server.

The browser provides three APIs for cross-tab coordination: BroadcastChannel for messaging, Web Locks for mutual exclusion, and SharedWorker for shared execution. They map directly to the concurrency primitives of pub/sub, mutex, and actor model.

BroadcastChannel

BroadcastChannel is a pub/sub bus across tabs. One tab posts a message; every other tab on the same origin receives it.

// Any tab can publish
const channel = new BroadcastChannel('app');
channel.postMessage({ type: 'CART_UPDATED', payload: { count: 3 } });

// Every other tab receives
channel.onmessage = ({ data }) => {
  if (data.type === 'CART_UPDATED') updateCartCount(data.count);
};

The sending tab does not receive its own message — only other tabs do.

Message types

Structure messages with a type field so receivers can route them:

const channel = new BroadcastChannel('app');

function broadcast(type, payload) {
  channel.postMessage({ type, payload });
}

channel.onmessage = ({ data }) => {
  switch (data.type) {
    case 'STORE_ACTION':    store.dispatch(data.payload); break;
    case 'AUTH_LOGOUT':     redirectToLogin(); break;
    case 'THEME_CHANGED':   applyTheme(data.payload.theme); break;
    case 'CART_UPDATED':    updateCartCount(data.payload.count); break;
  }
};

Store sync across tabs

Broadcast every store action so all tabs share the same state:

const channel = new BroadcastChannel('store-sync');

const originalDispatch = store.dispatch.bind(store);

store.dispatch = (action) => {
  originalDispatch(action);
  // Don't re-broadcast actions that came from another tab
  if (!action._fromBroadcast) {
    channel.postMessage({ type: 'STORE_ACTION', payload: action });
  }
};

channel.onmessage = ({ data }) => {
  if (data.type === 'STORE_ACTION') {
    originalDispatch({ ...data.payload, _fromBroadcast: true });
  }
};

The _fromBroadcast flag prevents an action received from another tab from being re-broadcast, which would cause an infinite loop.

Auth state sync

Log out in one tab and all tabs redirect to login:

function logout() {
  clearSession();
  broadcast('AUTH_LOGOUT', {});
  redirectToLogin();
}

channel.onmessage = ({ data }) => {
  if (data.type === 'AUTH_LOGOUT') redirectToLogin();
};

Tab lifecycle

BroadcastChannel messages are not persisted. A tab that opens after a message was broadcast will not receive it — it only receives messages sent while it's listening. If a new tab needs the current state, it must request it explicitly:

// New tab: ask for current state on open
channel.postMessage({ type: 'STATE_REQUEST' });

// Existing tabs: respond with their state
channel.onmessage = ({ data }) => {
  if (data.type === 'STATE_REQUEST') {
    channel.postMessage({ type: 'STATE_RESPONSE', payload: store.getState() });
  }
};

Why same-origin only

BroadcastChannel is restricted to the same origin (scheme + hostname + port). https://app.com and https://other.com cannot message each other — and neither can https://app.com and http://app.com.

This is a security boundary. If cross-origin messaging were allowed, a malicious site could listen to auth events, store actions, or any other message from your app. Same-origin restriction ensures only pages you control can participate in the channel.

Web Locks API

The Web Locks API is a mutex — it ensures only one tab can hold a named lock at a time. Any tab that requests a lock while another holds it waits until the lock is released.

// Request a lock — callback runs when lock is acquired
navigator.locks.request('my-lock', async (lock) => {
  // This code runs exclusively — no other tab holds 'my-lock' right now
  await doExclusiveWork();
  // Lock releases when the callback's promise resolves
});

The lock is held for the duration of the async callback and released automatically when it resolves or rejects. If the tab closes while holding the lock, the browser releases it automatically.

Lock modes

Web Locks supports two modes:

Exclusive (default) — only one holder at a time. Standard mutex behavior.

navigator.locks.request('resource', { mode: 'exclusive' }, async (lock) => {
  await writeToIndexedDB(); // safe, no other tab is writing
});

Shared — multiple tabs can hold the lock simultaneously, but an exclusive request waits for all shared holders to release. This is a read-write lock: many readers OR one writer.

// Multiple tabs can read concurrently
navigator.locks.request('resource', { mode: 'shared' }, async (lock) => {
  await readFromIndexedDB();
});

// Writer waits for all readers to finish
navigator.locks.request('resource', { mode: 'exclusive' }, async (lock) => {
  await writeToIndexedDB();
});

Leader election

The most common use case: elect one tab to own a long-lived resource (WebSocket, polling loop, background sync) and have others wait to take over if the leader closes.

async function acquireLeadership() {
  await navigator.locks.request('feed-leader', async (lock) => {
    // This tab is the leader
    await startWebSocketConnection();

    // Hold the lock until the tab closes — return a never-resolving promise
    await new Promise(() => {});
  });
}

acquireLeadership();

The new Promise(() => {}) never resolves, so the lock is held for the tab's entire lifetime. When the tab closes, the browser releases the lock and the next waiting tab acquires it and becomes the new leader.

Serializing IndexedDB writes

Two tabs writing to IndexedDB simultaneously can produce race conditions. An exclusive lock serializes them:

async function saveToIndexedDB(data) {
  await navigator.locks.request('idb-write', async () => {
    const db = await openDB('app', 1);
    await db.put('store', data, 'root');
  });
}

One-time work across tabs

Ensure a database migration or initialization only runs once, even if multiple tabs open simultaneously:

navigator.locks.request('db-migration', { ifAvailable: true }, async (lock) => {
  if (!lock) return; // another tab already holds it, skip
  await runMigrations();
});

ifAvailable: true makes the request non-blocking — if the lock is unavailable, the callback receives null instead of waiting.

SharedWorker

A SharedWorker is a single worker instance shared across all tabs on the same origin. Unlike a Web Worker (one per tab), a SharedWorker is created once and persists as long as at least one tab is connected to it.

// Each tab connects to the same worker instance
const worker = new SharedWorker('/shared-worker.js');
worker.port.start();
worker.port.postMessage({ type: 'SUBSCRIBE' });
worker.port.onmessage = ({ data }) => handleMessage(data);
// shared-worker.js
const ports = new Set();

self.onconnect = ({ ports: [port] }) => {
  ports.add(port);
  port.start();

  port.onmessage = ({ data }) => {
    // Broadcast to all connected tabs
    ports.forEach(p => p.postMessage(data));
  };

  port.addEventListener('close', () => ports.delete(port));
};

Centralized WebSocket

The most powerful use case: one WebSocket connection shared across all tabs. The worker owns the connection; tabs subscribe to events through it.

// shared-worker.js
let socket;
const ports = new Set();

self.onconnect = ({ ports: [port] }) => {
  ports.add(port);
  port.start();

  if (!socket) {
    socket = new WebSocket('wss://api.example.com/feed');
    socket.onmessage = ({ data }) => {
      ports.forEach(p => p.postMessage({ type: 'WS_MESSAGE', payload: JSON.parse(data) }));
    };
  }

  port.onmessage = ({ data }) => {
    if (data.type === 'WS_SEND') socket.send(JSON.stringify(data.payload));
  };
};

Every tab connects to the same worker. The worker opens one WebSocket and fans messages out to all tabs. When a tab sends a message, the worker forwards it through the single socket.

Centralized Server-Sent Events

SSE has the same problem as WebSocket — without coordination, each tab opens its own persistent connection to the server. SSE is actually a better fit for SharedWorker than WebSocket in one way: SSE is unidirectional (server → client only), so tabs never need to send messages back through the worker. Each tab makes outgoing requests directly; only incoming events flow through the shared connection.

// shared-worker.js
let eventSource;
const ports = new Set();

self.onconnect = ({ ports: [port] }) => {
  ports.add(port);
  port.start();

  if (!eventSource) {
    eventSource = new EventSource('/api/events');

    eventSource.onmessage = ({ data }) => {
      ports.forEach(p =>
        p.postMessage({ type: 'SSE_MESSAGE', payload: JSON.parse(data) })
      );
    };

    eventSource.onerror = () => {
      ports.forEach(p => p.postMessage({ type: 'SSE_ERROR' }));
    };
  }

  port.addEventListener('close', () => {
    ports.delete(port);
    // Close the connection when the last tab disconnects
    if (ports.size === 0) {
      eventSource.close();
      eventSource = null;
    }
  });
};

The connection lifecycle is cleaner than the Web Locks leader approach — when the last tab closes, the worker detects it via port.close and tears down the EventSource. A new connection opens when the next tab connects. No lock transfer, no re-election needed.

Shared in-memory cache

One cache across all tabs instead of each tab building its own:

// shared-worker.js
const cache = new Map();

self.onconnect = ({ ports: [port] }) => {
  port.start();
  port.onmessage = ({ data }) => {
    if (data.type === 'CACHE_GET') {
      port.postMessage({ id: data.id, value: cache.get(data.key) ?? null });
    }
    if (data.type === 'CACHE_SET') {
      cache.set(data.key, data.value);
    }
  };
};

Choosing the right primitive

The cleanest framing is to separate three concerns: coordination (who does the work), communication (how everyone finds out), and residency (where the state lives).

Gives youDoesn't give you
BroadcastChannelFan-out pub/sub to same-origin contextsExclusion, ordering, delivery guarantees, history
Web LocksMutual exclusion + a queue, crash-safeAny data transfer
Locks + BroadcastChannelLeader election and a way to push results to followersA home for state outside a tab
SharedWorkerA real singleton context with its own memory and lifetimeUniversal platform support; easy debugging

BroadcastChannel alone — when the message is the whole point and duplicate work isn't a concern. Logout propagation, theme changes, "the profile changed, refetch it." Fire-and-forget, nothing persists for tabs that open later.

Web Locks alone — when you need exactly one tab to do something and the result lands somewhere durable that others can just read. The canonical example is token refresh: every tab requests the same lock, the winner refreshes and writes to IndexedDB, the losers block in the queue and read the fresh token when they acquire. No broadcast needed — the lock queue is the "wait until it's done" barrier. Same shape for IndexedDB migrations, one-time bootstrap, and draining an offline mutation outbox.

Locks + BroadcastChannel — when one tab must own a resource and the others need results pushed to them, not pulled. One WebSocket shared across N tabs: one tab wins the lock, holds the connection, and broadcasts inbound messages. For WebSocket, follower tabs can't send back through the leader without a BroadcastChannel proxy — the leader must listen for outbound messages from followers and forward them through the socket. For SSE this doesn't apply; the stream is server → client only. Same pattern works for a shared poller or background sync loop.

The leader election idiom:

navigator.locks.request('leader', () => new Promise(() => {}));
// never resolves — held until the tab dies

The lock is released by the browser when the tab crashes or closes, so failover is automatic with no heartbeats and no TTLs. Use { ifAvailable: true } for a non-blocking "am I the leader?" check, and an AbortSignal to bound how long you'll wait.

SharedWorker — when you want the shared thing to live outside any tab. The WebSocket survives the tab that opened it, there's no reconnect gap because there's no failover, and each tab has a direct MessagePort to the worker so sending is symmetrical — no proxy needed. Expensive shared residents (a large parsed dataset, a WASM engine) aren't duplicated per tab.

The core tradeoff

SharedWorker gives you a true singleton. Locks + BroadcastChannel gives you an emulated one built out of an ordinary tab. The emulated version works essentially everywhere, but the leader can vanish mid-flight, gets timer-throttled when backgrounded on mobile, and can be discarded under memory pressure — so leader state must be recoverable from IndexedDB rather than living only in its heap, and in-flight work needs to be idempotent.

SharedWorker avoids all that but has real support gaps: Chrome on Android doesn't ship it, and Safari only re-added it in 16.4. Debugging means chrome://inspect/#workers rather than normal devtools.

The practical default is Locks + BroadcastChannel. SharedWorker is the upgrade when shared state is expensive enough that round-tripping through IndexedDB becomes the bottleneck. Two adjacent points worth noting: a Service Worker is not a substitute for either — it can be killed at any time and can't hold in-memory state. And all of these are same-origin only — a cross-origin iframe needs postMessage instead.

Composing all three

For a real-time feed with multiple tabs:

SharedWorker
  └── Owns the WebSocket connection (one connection, not N)
  └── Receives server events → forwards to all tabs via port.postMessage

Each tab
  └── Receives WS events from SharedWorker → dispatches to local store
  └── Broadcasts store actions via BroadcastChannel → other tabs stay in sync

Web Locks
  └── Serializes IndexedDB writes across tabs
  └── Ensures background sync (outbox flush) only runs in one tab at a time

BroadcastChannel handles messaging, Web Locks handles exclusion, and SharedWorker handles the shared resource. Each does one thing — they don't overlap, and together they cover the full coordination problem.

In a React app

Coordination logic belongs outside components. Components consume store state — they shouldn't know anything about how that state is kept in sync across tabs. The setup lives at the infrastructure layer: middleware, module initialization, or a single root effect.

BroadcastChannel — store middleware

BroadcastChannel needs to intercept every dispatched action and receive actions from other tabs before they hit the store. This is exactly what middleware is for.

Redux:

// store/broadcastMiddleware.ts
const channel = new BroadcastChannel('store-sync');

export const broadcastMiddleware = (store) => (next) => (action) => {
  const result = next(action);
  if (!action._fromBroadcast) {
    channel.postMessage(action);
  }
  return result;
};

// Wire up the receiver once, outside React
channel.onmessage = ({ data }) => {
  store.dispatch({ ...data, _fromBroadcast: true });
};

// store/index.ts
export const store = configureStore({
  reducer: rootReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(broadcastMiddleware),
});

Zustand:

// store/index.ts
const useStore = create((set, get) => ({ /* state */ }));

const channel = new BroadcastChannel('store-sync');

// Watch for state changes and broadcast them
useStore.subscribe((state, prevState) => {
  channel.postMessage({ state });
});

// Receive state from other tabs and merge
channel.onmessage = ({ data }) => {
  useStore.setState(data.state);
};

Web Locks — app entry point or root effect

Leader election and one-time initialization run once when the app starts — before or immediately after React mounts. A useEffect in the root <App> component with an empty dependency array is the right place:

// App.tsx
useEffect(() => {
  // Compete for leadership — only one tab wins
  navigator.locks.request('feed-leader', async () => {
    await startWebSocketConnection();
    await new Promise(() => {}); // hold until tab closes
  });

  // Flush outbox when this tab comes online
  window.addEventListener('online', flushOutbox);
  return () => window.removeEventListener('online', flushOutbox);
}, []);

For serializing IndexedDB writes, the lock lives in the data access utility — not in React at all:

// lib/db.ts
export async function writeToStore(data) {
  await navigator.locks.request('idb-write', async () => {
    const db = await openDB('app', 1);
    await db.put('store', data, 'root');
  });
}

Any component or hook that needs to write to IndexedDB calls writeToStore — the lock is invisible to them.

SharedWorker — module-level singleton

A SharedWorker must be initialized once, at the module level, outside of any React component. If you create it inside a component, each render (or each component mount) would attempt to create a new connection.

// lib/worker.ts — module-level singleton, initialized once on import
const worker = new SharedWorker('/shared-worker.js');
worker.port.start();

// Feed all worker messages directly into the Redux store
worker.port.onmessage = ({ data }) => {
  store.dispatch(data);
};

export function sendToWorker(message) {
  worker.port.postMessage(message);
}
// App.tsx — subscribe to worker events once at root
useEffect(() => {
  // worker.ts already wires up onmessage → store.dispatch
  // Nothing else needed in React — components just read from the store
}, []);

Components never interact with the worker directly. They dispatch actions and read from the store — the worker is just one of several sources that can push data into it.

The pattern

Infrastructure layer (outside React):
  BroadcastChannel middleware  → intercepts and receives store actions
  SharedWorker singleton       → owns WebSocket, feeds events into store
  Web Locks utility            → serializes writes, acquired in lib functions

React layer:
  Root <App> useEffect         → starts leader election, registers online listener
  Components                   → dispatch actions, read store state
  Hooks                        → compose store selectors, call lib functions

The React tree is the view layer. All coordination happens beneath it.