Web Platform · Browser Storage

Coordinating Tabs with Locks, BroadcastChannel, and Storage Events

How Web Locks give same-origin tabs a queue per name, what BroadcastChannel does and does not deliver, and why the storage event cannot serve as a mutex.

Several tabs of one origin share a single storage bucket, and nothing in the storage APIs serialises an application-level sequence of work between them. Two tabs can open the same IndexedDB database and run overlapping read-modify-write sequences; both reads succeed, both writes succeed, and one update is lost. The platform offers three coordination primitives with very different guarantees, and choosing between them is a question of whether the requirement is mutual exclusion, notification, or both.

The lost-update problem in one origin

A read-modify-write against IndexedDB is atomic only if it happens inside one transaction. IndexedDB provides that: a readwrite transaction holds its scope exclusively against other readwrite transactions on overlapping stores, across tabs as well as within one page. The gap appears when the operation cannot fit in a single transaction — refreshing an authentication token, rebuilding a cache, importing a file — because the work is asynchronous in a way the transaction cannot contain. The transaction lifetime rules forbid awaiting a network response inside one.

Those longer operations need an application-level lock, and an application-level lock is only sound if the check and the acquisition are atomic. Trying to build one from localStorage fails precisely there: getItem followed by setItem is two operations, and two tabs interleaving them both see an empty slot. The storage area has no compare-and-swap.

Web Locks: a queue keyed by a name

navigator.locks.request(name, callback) returns a promise that settles when the callback’s returned promise settles. The lock is held for exactly that interval, including any awaits inside the callback, and is released automatically. Requests for the same name queue in the order they were made; a request made while another holder is active waits rather than failing.

await navigator.locks.request('token-refresh', async () => {
  // Only one context in the origin runs this at a time.
  const token = await refreshFromServer();
  await storeToken(token);
});

The default mode is exclusive. { mode: 'shared' } allows concurrent holders, which models a readers-writer lock: many readers, one writer, with a pending exclusive request blocking later shared requests so that a constant stream of readers cannot starve a writer. The two modes interact through the same queue.

{ ifAvailable: true } changes the behaviour from waiting to probing: if the lock is held, the callback is invoked with null instead of a Lock, without entering the queue. That is the primitive for leader election: the first tab to acquire does the periodic work, the others observe that someone else is doing it:

let refreshing = false;

function tryBecomeRefresher() {
  navigator.locks.request('refresh-leader', { ifAvailable: true }, async (lock) => {
    if (!lock) return;             // another tab owns the role
    refreshing = true;
    while (refreshing) {
      await refreshCache();
      await new Promise((r) => setTimeout(r, 60_000));
    }
  });
}

The loop runs while the promise is pending, so the lock stays held for the whole period; the tab that owns it is the refresher for as long as it lives. A tab that is closed releases the lock and another tab’s request, if it was queued rather than probed, proceeds.

Locks are advisory and scoped to the storage bucket: they exist to coordinate cooperating code in the same origin, not to protect storage from other software. navigator.locks.query() returns a snapshot of held and pending locks with their names and modes, which is useful for diagnostics but is a snapshot, not a subscription.

Two hazards are structural. An exclusive lock is not reentrant: a context that requests a name it already holds exclusively queues behind itself and waits forever, so recursion into an exclusively locked section has to be avoided by construction. And a lock is held by a context, not by a page’s intent — a tab whose main thread is frozen, or one that is busy for a long time inside the callback, blocks every waiter for that name until it unfreezes or dies.

BroadcastChannel: fan-out with no history

new BroadcastChannel(name) creates an endpoint on a named channel scoped to the origin. postMessage delivers a structured clone of the value to every other endpoint with the same name in the same origin, and never to the sender. Message handlers run in the receiving context, so a channel is a way to tell peers that state changed, not a way to share state.

The channel has no persistence and no replay. A tab that subscribes after a message is sent never sees it, so a newly opened tab must read the current state from storage and use the channel only for subsequent changes. The clone follows the same rules as any other postMessage, so a class instance arrives as a plain object, and posting a function throws. Channels are available in windows, dedicated workers, and service workers, which makes them the usual way to tell a service worker that an asset changed; the storage event is not available in workers at all.

const channel = new BroadcastChannel('settings');
channel.onmessage = (event) => applySettings(event.data);

// After a local change, tell every other context.
channel.postMessage({ type: 'settings', theme: 'dark' });

Messages are delivered asynchronously and a channel does not acknowledge delivery. A sender cannot know whether any peer was listening, and there is no ordering guarantee relative to a concurrent write to IndexedDB: a peer can receive the notification before the write it refers to becomes visible if the write is still inside an uncommitted transaction. Notifications should carry the new state or a version number, and receivers should re-read rather than assume the store is current.

Why the storage event cannot be a mutex

The storage event fires in other documents after a localStorage value changes, and it is tempting to treat the write as the lock acquisition and the event as the queue. It fails on three counts. The event is not delivered to the writer, so the writer knows nothing about contention. The event carries oldValue and newValue, but no atomic conditional write exists, so two tabs can both observe the same empty value before either writes. And delivery is asynchronous, with no ordering defined relative to concurrent writes, so a peer cannot treat the event stream as a log of current state.

Events are also throttled or delayed in background tabs, and a document in the back/forward cache is not running its handlers at all. A scheme built on timeouts to work around these gaps is a race with a heuristic delay in the middle of it, and it fails when the scheduler is slow rather than when the logic is wrong.

What the storage event is good for is the narrow case it was designed for: telling already-open documents that a value they cached in memory has changed. A theme toggle, a sign-out in another tab, a draft saved elsewhere. These are changes where the receiver only needs to invalidate a local copy. Anything requiring exclusion belongs on navigator.locks, and anything requiring fan-out to workers belongs on BroadcastChannel.

SharedWorker as a single owner

A SharedWorker gives the origin one instance of a script shared by every connected document, reachable through a MessagePort. It is a different answer to the same problem: instead of electing a leader among tabs and having them contend, the work is moved into the single worker instance that all tabs connect to, and the tabs ask it to do things. The worker’s lifetime is tied to its connections — it can be terminated once the last port closes — so it cannot serve as a permanent coordinator, only as a rendezvous while at least one page is open.

The pattern suits an in-memory queue or a single connection to a server, where the state is cheap to rebuild and the point is to avoid duplicating it. It does not remove the need for a durable record: when the worker dies, whatever it held in memory is gone, and the next page to open must reconstruct it from storage. The Web Locks API covers much of the same ground with less machinery, since a lock can be held for the duration of an effort rather than for the lifetime of a context, but it cannot hold state between requests.

After a freeze or a crash

Both the lock and the channel assume the participating contexts are running. Browsers throttle background tabs and can freeze them entirely, suspending timers and message delivery while keeping the page’s state; a tab holding a lock and then frozen holds it for the duration of the freeze, and a queued request in another tab waits through it. A crash releases the lock when the browser terminates the owning execution context, because a lock belongs to a context and one that no longer exists holds nothing; a page restored from the back/forward cache, by contrast, resumes with whatever state it had and no indication of how long it was frozen.

The practical implication is that any lock holder should carry its own liveness signal: a heartbeat written into storage, a timestamp checked by the next acquirer, or work split into short critical sections that finish quickly enough to be unlikely to span a freeze. Locks are not leases and have no timeout unless the caller passes an AbortSignal, which aborts the wait, not the hold.

The primitives compose rather than compete. A lock serialises the writer, the write lands in IndexedDB, and a broadcast tells the other tabs to re-read. The missing piece in that chain is the tab that was not running when the broadcast happened; for that, the durable record in storage is the only source of truth, and every context has to read it on startup.

References

  1. Web Locks API — W3C
  2. HTML Standard — Broadcasting to other browsing contexts — WHATWG
  3. MDN — BroadcastChannel — MDN Web Docs