Skip to content
All posts
#Architecture#TypeScript#Performance

One Tab to Rule Them All: Leader Election with the Web Locks API

7 min read

The Web Locks API gives you cross-tab leader election with no heartbeat, no TTL, and no dependencies. A practical tour of modes, ifAvailable, signal, and why steal is a loaded weapon.

Open the same dashboard in four tabs and four copies of your sync loop start running: four polling intervals, four IndexedDB writers, four cache revalidations, all fighting over the same storage. The usual fix is a pile of heuristics: localStorage heartbeats, a BroadcastChannel ping-pong, a setInterval that hopes the tab you elected is still alive. The Web Locks API replaces that with a primitive that already exists in every browser you ship to.

The premise is small. A lock is an abstract concept representing some potentially shared resource, identified by a name chosen by the web app.[1] You ask for the lock by name, the browser grants it to exactly one context, and it releases automatically when your callback finishes.

javascript
navigator.locks.request("my_resource", async (lock) => {
  // The lock has been acquired.
  await do_something();
  await do_something_else();
  // Now the lock will be released.
});

That snippet is the whole mental model: the lock is requested, work is done while holding the lock in an asynchronous task, and the lock is automatically released when the task completes.[3] There is no release() call to forget in an error path — the lock is automatically released when the callback returns (or an exception is thrown).[2] Because request() returns a promise that resolves with the callback's result after the lock is released, you can await it and keep your code linear.[2]

Why this is better than a heartbeat

The Web Locks API allows scripts running in one tab or worker to asynchronously acquire a lock, hold it while work is performed, then release it, and while held, no other script executing in the same origin can acquire the same lock.[1] That gives you coordination without inventing a protocol.

The canonical use case is leader election. In a data synchronization service, a "primary tab" is designated — the only tab that should be performing network sync, cleaning up queued data, and the like — it holds a lock and never releases it, while other tabs queue behind it; if the primary tab crashes or is closed, one of the other tabs gets the lock and becomes the new primary.[4] That is the whole failover story, handled by the browser rather than your timeout logic — no TTL to tune, no window where two tabs both believe they are in charge.

Two more properties make it safe to adopt:

  • Scope is the origin, not the tab. Locks are scoped to origins; the locks acquired by a tab from https://example.com have no effect on the locks acquired by a tab from https://example.org:8080, as they are separate origins.[1] You are not coordinating with strangers; you are coordinating with yourself.
  • It is not a back channel. The spec is explicit that locks can be used as a communication channel and must be no more privileged than BroadcastChannel.[4] Using locks for coordination does not widen your privacy surface beyond where cross-tab messaging already sits.

The options that carry the weight

The four options are where the design decisions live.

mode — the mode option can be "exclusive" (the default if not specified) or "shared".[3] Exclusive means one holder at a time; shared means readers coexist. This shared/exclusive lock pattern is common in database transaction architecture — multiple simultaneous readers, a single writer — and the spec notes IndexedDB exposes the same semantics as readonly and readwrite transactions.[2] If your app reads a cached dataset from many components but writes it from one, shared for reads and exclusive for the write is a direct translation.

ifAvailable — if the ifAvailable option is true, the lock is only granted if it can be without additional waiting.[3] The callback is invoked with null instead of a Lock instance when it cannot be granted, and that is expected rather than an error.[2] This is the "am I the leader?" check: it is the difference between *becoming* the sync worker and *waiting in line* to become one.

signal — an AbortSignal can be passed so a lock request is dropped if it was not already granted.[2] If an abort is signalled before the lock is granted, the request promise rejects with an AbortError.[3] Use it to bound how long a tab waits before degrading gracefully. Note the asymmetry: once the lock has been granted, the signal is ignored.[3]

steal — this one is a loaded weapon. If a web application detects an unrecoverable state — for example, some coordination point like a Service Worker determines that a tab holding a lock is no longer responding — then it can "steal" a lock using this option.[3] The spec's own framing is that it is intended for use by web applications that need to attempt recovery in the face of application and/or user-agent defects, where behavior is already unpredictable.[3] The platform also rejects some combinations outright: NotSupportedError is thrown if the name starts with a hyphen, if both steal and ifAvailable are true, or if signal exists alongside either.[2]

Debugging and deadlocks

The query() method of the LockManager interface returns a promise that resolves with an object containing information about held and pending locks.[5] Each entry carries the name, the mode, and a clientId — the unique identity of the context where LockManager.request() was called, the same value as Client.id.[5] That answers "which tab holds the sync lock right now" from a service worker or devtools console.

The failure mode to watch for is deadlock: if tab 1 holds lock A and tab 2 holds lock B, and each then requests the other's lock, neither can be granted. The spec's remedies are to avoid nesting lock requests, keep them well ordered, or give them timeouts — and it notes such deadlocks only affect the locks themselves and the code depending on them, not the browser, other tabs, or other script on the page.[1] In practice: one lock per critical section, and never request a second while holding one.

What you can rely on

Baseline status is the reason this is worth adopting now rather than filing away. The feature is well established and works across many devices and browser versions, having been available across browsers since March 2022.[1] MDN's browser-compat-data records it as Chrome 69, Firefox 96, and Safari 15.4[7] — and the Chrome 69 release notes, last updated 2018-09-04, described it exactly as it behaves today, coordinating shared resources so that when a second tab tries to acquire the same lock, the request is queued until the first releases it.[6]

It requires a secure context and is available in Web Workers as well as windows[1], which matters: the same API coordinates a page, a dedicated worker, and a service worker.

The cost of admission is that locks are cooperative. Nothing stops a tab that never calls request() from writing to IndexedDB; the API serializes the participants that opt in. That is the same constraint every cross-tab scheme has — except this one is enforced by the browser, ships with the platform, and comes with no dependencies.

Next time you reach for a localStorage heartbeat to decide which tab does the work, try ifAvailable first. One lock, one leader, and the browser handles the handoff.

Sources