This page is part of the Session State Persistence & Hydration Fallbacks section, which covers the broader contract for keeping user-visible state consistent across crashes, reloads, and — as covered here — multiple open tabs of the same origin.
The problem: state that diverges the moment a second tab exists #
A user opens your app in one tab to review an order, then opens a second tab to start a new one. In the second tab they log out. The first tab has no way of knowing this happened — its in-memory store still holds the old auth token, its UI still renders the authenticated header, and the next request it fires goes out with credentials the server has already invalidated. The user sees a ghost session: a page that looks logged in but silently 401s on every action until they reload.
The inverse failure is just as common. Both tabs have a shopping cart open. The user removes an item in tab A. In tab B, unaware of the change, they click “increase quantity” on the same line item a second later. If both tabs are writing the same localStorage key independently — each reading, mutating, and writing back the full cart object — the last write wins and silently clobbers whatever the other tab did. There is no error, no console warning, just a cart that doesn’t match what either tab’s UI showed a moment before.
Both failures trace back to the same root cause: browser tabs of the same origin are separate JavaScript realms with separate in-memory state, but they share the same storage and, in most deployments, the same backend session. Without an explicit synchronization layer, “the same app” silently becomes several independent, diverging copies of it the instant a user opens a second tab. User-visible consequences: contradictory UI between tabs, actions that appear to succeed but are based on stale assumptions, and writes to shared storage that vanish because a concurrent write in another tab overwrote them first.
Prerequisites #
Core implementation: a BroadcastChannel sync layer with storage-event fallback #
The layer below sits between the store and the outside world. It listens to store mutations, re-broadcasts a subset of them as small “intents” to other tabs, applies incoming intents idempotently, and dedupes by tagging every message with the originating tab’s id.
// cross-tab-sync.ts
type StoreIntent =
| { type: 'AUTH_SET'; token: string | null }
| { type: 'CART_ITEM_REMOVED'; itemId: string }
| { type: 'CART_QUANTITY_SET'; itemId: string; quantity: number };
interface SyncMessage {
tabId: string;
intent: StoreIntent;
ts: number;
}
// One id per tab lifetime — never persisted, so a reload always gets a fresh id
const TAB_ID = crypto.randomUUID();
const CHANNEL_NAME = 'app-state-sync-v1';
const FALLBACK_KEY = '__sync_fallback_v1__';
// Minimal store contract this layer depends on — adapt to Redux/Zustand's real API
interface SyncableStore {
dispatch(intent: StoreIntent): void;
subscribeToIntents(fn: (intent: StoreIntent) => void): () => void;
}
export function createCrossTabSync(store: SyncableStore) {
const supportsBroadcastChannel = typeof BroadcastChannel !== 'undefined';
const channel = supportsBroadcastChannel
? new BroadcastChannel(CHANNEL_NAME)
: null;
// Guards against applying our own broadcast back onto ourselves and
// against re-broadcasting a message we just received from another tab.
let isApplyingRemote = false;
function applyRemoteIntent(message: SyncMessage) {
if (message.tabId === TAB_ID) return; // echo of our own message — ignore
isApplyingRemote = true;
try {
store.dispatch(message.intent); // idempotent apply — see architecture note
} finally {
isApplyingRemote = false;
}
}
function broadcast(intent: StoreIntent) {
if (isApplyingRemote) return; // never re-emit a message we just applied
const message: SyncMessage = { tabId: TAB_ID, intent, ts: Date.now() };
if (channel) {
channel.postMessage(message);
} else {
// Fallback: write-then-remove forces a 'storage' event in OTHER tabs.
// The value itself carries the payload; the key never needs a stable read.
localStorage.setItem(FALLBACK_KEY, JSON.stringify(message));
localStorage.removeItem(FALLBACK_KEY);
}
}
if (channel) {
channel.onmessage = (event: MessageEvent<SyncMessage>) => {
applyRemoteIntent(event.data);
};
}
// storage events fire in every OTHER same-origin tab automatically —
// this tab's own writes never trigger its own 'storage' listener.
function handleStorageEvent(event: StorageEvent) {
if (event.key !== FALLBACK_KEY || !event.newValue) return;
try {
const message = JSON.parse(event.newValue) as SyncMessage;
applyRemoteIntent(message);
} catch {
// Malformed payload from a stale bundle version — drop it silently
}
}
if (!channel) {
window.addEventListener('storage', handleStorageEvent);
}
// Mirror store mutations outward — subscribeToIntents fires only for
// intents the store's own reducer produced, so this never sees remote applies.
const unsubscribe = store.subscribeToIntents(broadcast);
return function teardown() {
unsubscribe();
channel?.close();
if (!channel) window.removeEventListener('storage', handleStorageEvent);
};
}
Architecture note #
Three transports can move state between tabs, and they are not interchangeable. BroadcastChannel is a purpose-built, same-origin pub/sub channel: it delivers structured JS values (no manual serialization for most types), has no persistence side effect, and every open tab receives every message immediately. The storage event is a side effect of writing to localStorage — it only fires in other tabs, never the writer, which is convenient for dedupe but means you’re piggy-backing a messaging protocol on top of a persistence API that was never designed for it; every payload must round-trip through JSON.stringify/parse, and quota limits apply. A SharedWorker goes further still: a single script instance shared by every tab in the origin, capable of holding a WebSocket connection itself rather than delegating that job to one tab — worth it when you need message ordering guarantees or heavier coordination logic, but it’s a separate script context to build, debug, and hot-reload, so most apps get equivalent behavior more cheaply from BroadcastChannel plus leader election.
The message shape matters as much as the transport. Every SyncMessage carries a tabId so a tab can recognize and drop its own echoes, and a ts for tie-breaking when two messages arrive close together. Applying an intent must be idempotent — dispatching CART_ITEM_REMOVED for an item that’s already gone should be a no-op, not an error — because network jitter, tab suspension, and message replay after a service worker update can all cause the same intent to arrive twice. This is also why the sync layer broadcasts intents (CART_ITEM_REMOVED, AUTH_SET) rather than whole state trees: an intent is small, replays safely, and lets each tab’s own reducer be the single source of truth for how that intent affects derived state, instead of trusting a snapshot that might already be stale by the time it’s applied.
Edge cases and gotchas #
| Failure mode | Symptom | Mitigation |
|---|---|---|
| Echo loop | A tab re-broadcasts a message it just received, causing exponential message growth | Tag messages with tabId; apply remote intents through a path that never re-triggers the outbound broadcaster |
| Out-of-order delivery | Two rapid mutations from different tabs arrive in a different order than they were dispatched | Include a monotonic ts or server-issued sequence number; last-writer-wins by timestamp, not by arrival order |
| Duplicate WebSocket/poller per tab | Every open tab opens its own socket to the same endpoint, multiplying server load and rate-limit consumption | Leader election with navigator.locks; only the lock holder opens the connection |
| Serialization limits | Broadcasting a Map, Set, function, or circular object throws or silently drops fields |
Broadcast plain, JSON-serializable intents only — never store instances or DOM references |
| Private-mode / partitioned storage | BroadcastChannel or storage events silently stop firing between tabs |
Treat sync as progressive enhancement; fall back to a server-driven refresh for correctness-critical state |
| Stale bundle version mismatch | An old tab (pre-deploy) broadcasts a message shape a new tab doesn’t recognize | Version the channel name and message schema (app-state-sync-v1); ignore unknown type values instead of throwing |
Advanced variant: leader election for shared connections #
Broadcasting store mutations solves divergence, but it doesn’t solve duplication: if five tabs are open and each one independently holds a WebSocket to your realtime endpoint, you’re paying for five connections and five sets of reconnect/backoff logic to keep in sync. navigator.locks lets exactly one tab hold a named lock at a time — including automatically re-electing a new leader when the current holder closes — without any server coordination.
// leader-election.ts
type LeaderCallback = (signal: AbortSignal) => Promise<void>;
/**
* Runs `onLeader` in exactly one tab at a time. If the current leader
* tab closes or crashes, the lock is released and another tab's pending
* request resolves — the browser handles the failover, not your code.
*/
export function runAsLeader(onLeader: LeaderCallback): () => void {
const abortController = new AbortController();
// navigator.locks.request never resolves until the callback's promise
// settles, so an infinite-running callback effectively "holds" the role.
void navigator.locks.request(
'app-leader-v1',
{ mode: 'exclusive', signal: abortController.signal },
async (lock) => {
if (!lock) return; // request was aborted before the lock was granted
await onLeader(abortController.signal);
}
).catch((err) => {
if (err instanceof DOMException && err.name === 'AbortError') return;
console.warn('[cross-tab-sync] leader election aborted:', err);
});
return () => abortController.abort();
}
// Usage: only the elected leader opens the socket and rebroadcasts
// server pushes to followers via the same sync channel used for local intents.
export function startLeaderDrivenSync(
broadcastToFollowers: (intent: unknown) => void
) {
return runAsLeader(async (signal) => {
const socket = new WebSocket('wss://api.example.com/session-updates');
socket.onmessage = (event) => {
const intent = JSON.parse(event.data);
broadcastToFollowers(intent); // fan out to every other tab
};
// Keep the lock callback alive until the leader is torn down
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => {
socket.close();
resolve();
});
});
});
}
Every tab calls startLeaderDrivenSync on load; the browser resolves the race so only one tab’s callback ever runs concurrently. When the leader tab is closed, its lock is released automatically and the next tab in the queue takes over — typically within a single event loop turn, well before a user would notice the WebSocket reconnect. Followers never open their own socket; they receive server-driven updates purely through the same BroadcastChannel sync layer built above, which keeps the message-handling code path identical whether an intent originated from a local store mutation or from the leader’s socket.
Testing and CI/CD validation #
Cross-tab bugs are notoriously hard to catch in unit tests because they require two independent JS realms sharing storage — Playwright’s multi-context API is the right tool because each BrowserContext behaves like an isolated tab while still sharing the same origin’s storage and BroadcastChannel.
import { test, expect, type BrowserContext, type Page } from '@playwright/test';
test('mutation in one tab is reflected in a second tab without an echo loop', async ({ browser }) => {
const contextA: BrowserContext = await browser.newContext();
const contextB: BrowserContext = await browser.newContext({ storageState: undefined });
const pageA: Page = await contextA.newPage();
const pageB: Page = await contextB.newPage();
await pageA.goto('https://app.example.test/cart');
await pageB.goto('https://app.example.test/cart');
// Track outgoing broadcasts in tab B to assert no echo loop occurs
await pageB.evaluate(() => {
(window as any).__broadcastCount = 0;
const channel = new BroadcastChannel('app-state-sync-v1');
channel.addEventListener('message', () => {
(window as any).__broadcastCount++;
});
});
// Mutate the cart in tab A
await pageA.getByRole('button', { name: 'Remove item' }).click();
// Tab B's UI should converge to the same state
await expect(pageB.getByTestId('cart-item-count')).toHaveText('0');
// Give any potential echo loop time to manifest, then assert it didn't
await pageB.waitForTimeout(500);
const broadcastCount = await pageB.evaluate(() => (window as any).__broadcastCount);
expect(broadcastCount).toBe(1); // exactly one message received, no re-broadcast storm
await contextA.close();
await contextB.close();
});
Run this alongside a same-context test that forces BroadcastChannel to be undefined (via page.addInitScript) to exercise the storage-event fallback path explicitly — the two transports have different timing characteristics and deserve independent coverage rather than relying on one to implicitly validate the other.
For the specific case of session and authentication state, the mechanics above become sharper-edged: a logout in one tab must propagate before the next authenticated request fires from a stale tab, which the Syncing Auth and Logout Across Tabs with BroadcastChannel deep-dive covers with a full token-invalidation sequence and race-condition test suite.
Frequently asked questions #
Why use BroadcastChannel instead of just relying on the storage event? BroadcastChannel delivers structured messages directly between same-origin contexts without touching disk, so it’s faster and doesn’t compete with real persistence writes. The storage event only fires in tabs other than the writer and forces every payload through JSON serialization, but it remains valuable as a fallback for contexts where BroadcastChannel isn’t available.
Should I sync the entire store or just specific mutations? Sync intents, not whole state trees. A full-snapshot broadcast is expensive to serialize, races badly under concurrent mutation, and can overwrite fields the receiving tab never touched. A small, named intent lets every tab apply the same reducer logic locally instead of trusting a snapshot that may already be stale.
How do I stop tabs from re-broadcasting messages they just received? Tag every outgoing message with the sending tab’s id and check it on receipt. Apply incoming messages through a code path that updates the store without also triggering the outbound broadcaster — otherwise a tab will echo the same update back out and create a growing message loop.
What happens to sync in Safari private browsing or storage-partitioned contexts? BroadcastChannel and the storage event both need a shared, unpartitioned storage context for the origin. Under Intelligent Tracking Prevention or certain private-mode configurations that isolation breaks, so cross-tab sync silently stops working even though same-tab persistence still functions. Treat it as progressive enhancement and keep a server-driven refresh path for correctness-critical state.
Do I need leader election if I only sync client-side state?
No. Leader election matters when a shared external resource — a WebSocket or a polling interval — would otherwise be duplicated per open tab. If your sync layer only mirrors local store mutations between tabs, every tab can broadcast on equal footing without navigator.locks.
Related #
- Session State Persistence & Hydration Fallbacks — parent section
- Syncing Auth and Logout Across Tabs with BroadcastChannel — the auth/logout deep-dive built on this sync layer
- LocalStorage & IndexedDB Sync Strategies — the persistence layer that cross-tab messages ultimately need to agree with
- Hydration Mismatch & State Recovery — reconciling server-rendered state with the client store this sync layer mutates