Cross-Tab Shared State
The problem: keeping tabs in sync
Section titled “The problem: keeping tabs in sync”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:
localStorage(or IndexedDB) — the persistent source of truth.- BroadcastChannel — instant notification to all other tabs when state changes.
- Web Locks — prevent two tabs from writing at the same time (leader election or write serialisation).
Pattern: broadcast-on-write
Section titled “Pattern: broadcast-on-write”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 tabsch.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.
Leader election with Web Locks
Section titled “Leader election with Web Locks”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.