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.
Runnable: shared counter sketch
Section titled “Runnable: shared counter sketch”Trade-offs
Section titled “Trade-offs”| Option | Benefit | Cost |
|---|---|---|
Combining all 3 primitives (localStorage + BroadcastChannel + Web Locks) | Gives you a shared-state layer that’s persistent, real-time, and safe from race conditions | More complex than a single primitive — you maintain all three concepts together, and debugging gets harder |
| Broadcast-on-write with no lock | Simple code, no queue to wait on | Risks a race condition when two tabs write at nearly the same time and overlap |
| Leader election with Web Locks | Only one tab does heavy work, like polling an API, saving resources and avoiding duplicate requests | If the leader tab hangs without releasing the lock, no other tab can ever become leader |
Common mistakes
Section titled “Common mistakes”- Relying on the
storageevent 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
localStorageto recover it.