Skip to content

Cross-Tab Shared State

Imagine a multi-tab dashboard. Tab A increments a counter. Tab B should see the new value without polling. The storage event on localStorage can notify Tab B, but it only carries the raw new string value — there is no structured state layer built in.

A robust solution combines three primitives:

  1. localStorage (or IndexedDB) — the persistent source of truth.
  2. BroadcastChannel — instant notification to all other tabs when state changes.
  3. Web Locks — prevent two tabs from writing at the same time (leader election or write serialisation).

The simplest pattern: every tab that writes state also broadcasts the change.

const ch = new BroadcastChannel('demo:app-state');
async function setState(key, value) {
await navigator.locks.request('demo:state-write', async () => {
localStorage.setItem(key, JSON.stringify(value));
ch.postMessage({ type: 'STATE_UPDATE', key, value });
});
}
// React to changes from other tabs
ch.onmessage = (e) => {
if (e.data.type === 'STATE_UPDATE') {
console.log('Remote update:', e.data.key, '=', e.data.value);
// Update local UI here
}
};

The lock ensures that if two tabs try to write simultaneously, they queue rather than race.

For expensive work (e.g., a single tab polling an API), you want exactly one “leader” tab to do the work and broadcast results to followers. Web Locks make this straightforward:

async function runAsLeader() {
// Only one tab can hold 'leader' at a time.
// The lock is held for the entire async block — when this tab closes the lock
// releases and the next queued tab becomes leader.
await navigator.locks.request('demo:leader', async () => {
console.log('I am the leader');
while (true) {
const data = await fetchLatestData();
ch.postMessage({ type: 'DATA_REFRESH', data });
await new Promise((r) => setTimeout(r, 5000));
}
});
}
runAsLeader();

When the leader tab is closed, the browser releases its lock and the next queued tab’s callback fires — automatic leader handoff with no extra code.

Browser Storage
OptionBenefitCost
Combining all 3 primitives (localStorage + BroadcastChannel + Web Locks)Gives you a shared-state layer that’s persistent, real-time, and safe from race conditionsMore complex than a single primitive — you maintain all three concepts together, and debugging gets harder
Broadcast-on-write with no lockSimple code, no queue to wait onRisks a race condition when two tabs write at nearly the same time and overlap
Leader election with Web LocksOnly one tab does heavy work, like polling an API, saving resources and avoiding duplicate requestsIf the leader tab hangs without releasing the lock, no other tab can ever become leader
  • Relying on the storage event to sync state within the same tab that wrote it — this event only fires in other tabs. The writing tab must update its own UI with separate code.
  • Not wrapping a read-then-write (check-then-act) sequence in the same lock callback — if the read happens outside the lock, a race condition can still occur even though you’re using Web Locks.
  • Letting the leader tab do heavy work with no fallback for when every tab closes — leader election restarts from scratch every time the first tab opens; no tab “remembers” the previous state, so you still need localStorage to recover it.
Why wrap a localStorage write in navigator.locks.request when multiple tabs may write?
In the leader-election pattern, what happens when the leader tab is closed?
Which storage API is used as the persistent source of truth in the broadcast-on-write pattern shown above?
Does the tab that calls BroadcastChannel.postMessage receive its own message event?