Web Locks API for Cross-Tab Coordination

Every open tab of your origin runs its own copy of your JavaScript, its own timers, and its own event handlers. When three tabs all detect the network coming back online, three tabs flush the same queue, three tabs run the same IndexedDB migration, and the user gets duplicate records and corrupted state. The Web Locks API (navigator.locks) is the platform’s answer: a same-origin mutex that lets one tab claim a named lock while the others wait or skip, with automatic release when the holder finishes or its tab closes. For foundational context on the storage primitives these tabs share, review Browser Storage Fundamentals & Quotas. This guide covers the full API surface, the exclusive and shared lock modes, timeout and no-op patterns, and the lifecycle guarantees that make navigator.locks far safer than the localStorage mutex hacks it replaces.

Three tabs requesting one named exclusive lock A sequence showing three tabs requesting the same named lock, one acquiring it and running the critical section while the others wait, then the lock passing to the next waiter on release. Tab A Tab B Tab C locks.request name: "sync-flush" Critical section one tab at a time Callback promise settles or tab closes Lock auto-releases; next waiter is granted in FIFO order

What the Web Locks API gives you

A lock is identified by an arbitrary string name you choose, scoped to a single origin and shared across every same-origin tab, iframe, dedicated worker, shared worker, and service worker. You never hold a lock object directly. Instead you call navigator.locks.request(name, callback); the browser grants the lock, invokes your callback, and keeps the lock held for exactly as long as the promise your callback returns stays pending. The moment that promise settles — resolve or reject — the lock releases. This callback-scoped lifetime is the API’s central design decision: there is no release() to forget, no lock leaked when an exception throws, and no dangling lock when the user closes the tab mid-operation.

The full request signature is navigator.locks.request(name, options?, callback), where options is optional and may be omitted entirely. A separate navigator.locks.query() returns a snapshot of which locks are currently held and which requests are pending — invaluable for debugging contention. The table below lists every parameter and method.

Member Type Purpose
request(name, callback) Promise Acquire lock name; run callback while held; resolves with the callback’s return value.
request(name, options, callback) Promise Same, with an options bag controlling mode, availability, abort, and stealing.
options.mode 'exclusive' | 'shared' 'exclusive' (default) blocks all others; 'shared' allows concurrent shared holders.
options.ifAvailable boolean If true and the lock is taken, the callback runs immediately with null instead of waiting.
options.signal AbortSignal Abort a pending request; the request promise rejects with AbortError.
options.steal boolean Forcibly release existing holders and grant the lock to this request. Use with extreme care.
query() Promise<LockManagerSnapshot> Returns { held, pending } arrays describing current lock state for the origin.
callback(lock) Function Your work. Receives the granted Lock (or null when ifAvailable found it busy).

The Lock object passed to your callback exposes only lock.name and lock.mode; its value is observability, not control. Control is entirely temporal — you hold the lock as long as your promise is pending.

Serializing a critical section

The canonical use is wrapping a critical section so only one tab executes it at a time. Because the lock is held for the lifetime of the returned promise, you simply make your callback async and await the work inside it.

// Only one tab across the origin runs performSync() at any moment.
// The lock is held until the async callback's promise settles.
async function syncOnce(): Promise<void> {
  await navigator.locks.request('app-sync', async (lock) => {
    // `lock` is non-null here because we waited until it was granted.
    console.info(`Acquired ${lock?.name} in ${lock?.mode} mode`);
    await performSync();
    // Returning ends the callback's promise -> lock releases automatically.
  });
}

async function performSync(): Promise<void> {
  const pending = await readOutbox();
  for (const item of pending) {
    await fetch('/api/sync', { method: 'POST', body: JSON.stringify(item) });
    await markSynced(item.id);
  }
}

// Stubs representing your storage layer.
declare function readOutbox(): Promise<{ id: string }[]>;
declare function markSynced(id: string): Promise<void>;

If a second tab calls syncOnce() while the first holds app-sync, its request promise stays pending until the first callback finishes, then the second callback runs. Work is serialized, never concurrent.

Shared vs exclusive locks for reader/writer patterns

The two modes implement a classic readers-writer lock. Any number of 'shared' holders can run at once, but an 'exclusive' request waits until every shared holder releases, and while an exclusive lock is held no shared request is granted. Use shared locks for readers that tolerate concurrency and an exclusive lock for the writer that must run alone.

// Readers acquire the shared lock concurrently; a writer waits for all of
// them to finish, then runs exclusively while readers queue behind it.
async function readSnapshot(): Promise<Snapshot> {
  return navigator.locks.request('config', { mode: 'shared' }, async () => {
    return loadConfigFromIndexedDb();
  });
}

async function rewriteConfig(next: Snapshot): Promise<void> {
  await navigator.locks.request('config', { mode: 'exclusive' }, async () => {
    await writeConfigToIndexedDb(next);
  });
}

interface Snapshot { version: number; data: Record<string, unknown>; }
declare function loadConfigFromIndexedDb(): Promise<Snapshot>;
declare function writeConfigToIndexedDb(s: Snapshot): Promise<void>;

Skipping work with ifAvailable

Sometimes waiting is wrong: if another tab is already flushing the queue, a second flush is redundant, not urgent. Pass ifAvailable: true and the browser will not queue your request. If the lock is free it runs normally; if it is taken, your callback fires immediately with a null lock so you can detect the no-op and return.

// If another tab already holds the lock, skip rather than queue.
async function flushIfIdle(): Promise<'ran' | 'skipped'> {
  return navigator.locks.request(
    'app-sync',
    { ifAvailable: true },
    async (lock) => {
      if (lock === null) return 'skipped'; // someone else is flushing
      await performSync();
      return 'ran';
    },
  );
}

Timeouts with AbortSignal

A pending request can wait forever if the holder never releases. Bound the wait with an AbortSignal. AbortSignal.timeout(ms) produces a signal that fires after a delay, and the request promise rejects with AbortError if the lock is still not granted by then. The signal only cancels a pending request — once the lock is granted the callback runs to completion regardless.

// Wait at most 3 seconds for the lock; otherwise give up cleanly.
async function syncWithDeadline(): Promise<void> {
  try {
    await navigator.locks.request(
      'app-sync',
      { signal: AbortSignal.timeout(3000) },
      async () => { await performSync(); },
    );
  } catch (err) {
    if (err instanceof DOMException && err.name === 'AbortError') {
      console.warn('Could not acquire app-sync within 3s; another tab is busy');
      return;
    }
    throw err;
  }
}

Concurrency and lifecycle guarantees

The lifecycle rules are what make the API reliable, and they are worth stating precisely:

Contrast this with the patterns teams used before navigator.locks shipped. The old localStorage “mutex” wrote a flag key, slept, and re-checked — racy, because two tabs can read an empty flag in the same tick, and leak-prone, because a crashed tab leaves the flag set forever. BroadcastChannel lets tabs message each other but provides no mutual exclusion primitive; you would have to build election and timeout logic by hand on top of it. The Web Locks API replaces both with a single guaranteed-release mutex. BroadcastChannel remains useful as a fallback for browsers without locks and for broadcasting results after the lock-holder finishes its work.

Error handling

Because the lock is tied to the callback promise, error handling is mostly a matter of letting rejections propagate correctly:

// A throw inside the callback rejects the callback promise, which both
// releases the lock and rejects the outer request promise.
async function guardedMigration(): Promise<void> {
  try {
    await navigator.locks.request('db-migrate', async () => {
      await runMigration();      // if this throws, the lock still releases
    });
  } catch (err) {
    // The lock is already released by the time we get here.
    console.error('Migration failed; lock released for retry', err);
    throw err;
  }
}

declare function runMigration(): Promise<void>;

Two failure modes deserve explicit handling. First, a rejected callback releases the lock but rejects the outer request promise, so always wrap critical-section calls in try/catch or attach .catch(); an unhandled rejection here is a silent loss of a sync run. Second, AbortError from a signal timeout is an expected control-flow outcome, not a bug — branch on err.name === 'AbortError' and treat it as “another tab is handling it.” Avoid steal: true for error recovery; it forcibly releases a holder that may be mid-write, which can corrupt exactly the data the lock was protecting.

Browser compatibility

The Web Locks API is Baseline and available in every current major browser. Safari was the last to ship it, in 16.4, so the practical floor for cross-browser support is broad. The API is also available inside workers, including service workers, which is where background-sync coordination usually lives.

Browser navigator.locks Notes
Chrome / Edge 69+ Full support including query() and steal. Available in service workers.
Firefox 96+ Full support. ifAvailable and signal behave per spec.
Safari (macOS) 15.4+ Shipped behind the Web Locks implementation; stable from 16.4 onward.
iOS Safari 16.4+ Available in tabs and installed PWAs. Test the Home Screen context separately.
Workers Yes Dedicated, shared, and service workers can all request locks for the origin.

On iOS Safari, confirm navigator.locks exists before relying on it (older 15.x and 16.0–16.3 builds may lack it), and remember that an installed Home Screen PWA can run in a slightly different storage and process context than the in-browser tab — verify lock coordination in the exact context you ship. Feature-detect with a simple guard:

// Detect support and fall back when locks are unavailable.
function hasWebLocks(): boolean {
  return typeof navigator !== 'undefined' && 'locks' in navigator;
}

async function coordinated(name: string, work: () => Promise<void>): Promise<void> {
  if (hasWebLocks()) {
    await navigator.locks.request(name, { ifAvailable: true }, async (lock) => {
      if (lock) await work();
    });
  } else {
    await work(); // degrade: rely on server-side idempotency instead
  }
}

Performance and scale

Locks are cheap to acquire when uncontended, but contention is the cost center. Three principles keep coordination fast:

Use navigator.locks.query() in development to watch contention. A growing pending array for one name is a signal that the holder’s critical section is too long or the lock is too coarse.

The highest-value applications are deduplicating a background flush so only one tab posts to the server, leader election where one tab holds a long-lived lock and owns shared work (a single WebSocket, a single polling loop), and guarding an IndexedDB schema migration so two tabs never run onupgradeneeded logic against the same database at once. The deduplication case has its own complete walkthrough in Preventing Duplicate Sync with Web Locks, and the lock pairs naturally with the queue mechanics in Background Sync API Implementation. For large binary coordination you may also be juggling the Origin Private File System (OPFS), whose access handles benefit from the same single-writer discipline.

Frequently Asked Questions

When exactly does a Web Lock release?

The instant the promise your callback returns settles — whether it resolves or rejects — or when the holding tab is closed or crashes. There is no manual release() call. Hold a lock longer by awaiting more work inside the callback; release sooner by returning earlier.

What happens to a lock if the tab holding it crashes?

The browser releases it automatically. This is the key advantage over the old localStorage mutex, which would leave a stale flag set forever if the holding tab died mid-operation. A waiting tab is granted the lock as soon as the dead holder’s lock is cleaned up.

Should I use the Web Locks API or BroadcastChannel?

Use the Web Locks API when you need mutual exclusion — exactly one tab doing something. Use BroadcastChannel to send messages between tabs. They complement each other: a leader elected by a lock can broadcast results to followers. BroadcastChannel alone provides no locking, so it is a fallback, not a replacement.

Can a lock be held across an await without being lost?

Yes. The lock persists for the entire lifetime of the callback’s returned promise, so it is held across every await inside an async callback. That is precisely why you should keep critical sections short — a long await chain keeps every other tab waiting.

Related