Area: Coordinating tabs and workers
Related: Browser APIs (map), BroadcastChannel, IndexedDB, Web Workers

What is it?

The Web Locks API gives you named locks shared by every tab and worker of the same origin. You ask for a lock by name, the browser makes you wait until nobody else holds it, runs your callback, and releases the lock when the callback’s promise settles.

await navigator.locks.request("sync", async (lock) => {
  // Only one tab or worker at a time gets in here.
  await doTheSync();
}); // released here, even if doTheSync throws

A mutex for the whole origin. The browser is the referee, so it works across tabs, and it can’t get stuck when a tab crashes.

Why it exists

Before Web Locks, people built cross-tab locks out of localStorage: write "lock": "tab-42, expires 12:00:05", check it, hope nobody else wrote it at the same moment. That approach has two problems you can’t fix:

  1. Races. Two tabs read “unlocked” at the same moment and both take the lock. localStorage has no compare-and-swap.
  2. Crashed holders. A tab takes the lock and then crashes or gets killed. The lock stays “held” until its expiry time runs out, so you’re forced to pick between short expiries (two holders) and long ones (stuck for ages).

Web Locks fixes both. The browser itself hands out the lock, so there’s no race. And when a tab closes, crashes, or its worker is terminated, the browser releases every lock it held right away.

The API in full

navigator.locks.request(name, callback);
navigator.locks.request(name, options, callback);

The callback gets a lock object (or null, see ifAvailable). The lock is held until the promise returned by the callback settles. Return a promise that never settles and you hold the lock for as long as the tab lives.

request() itself returns a promise that resolves with whatever your callback returned.

Options

OptionMeaning
mode: "exclusive" (default)Only one holder at a time.
mode: "shared"Any number of shared holders at once, but never alongside an exclusive holder. That’s a read/write lock: readers take shared, the writer takes exclusive.
ifAvailable: trueDon’t wait. If the lock is taken, call the callback right away with null.
steal: trueTake the lock even if someone holds it. The old holder’s lock is released, and its request() promise rejects with an AbortError. For recovery only.
signalAn AbortSignal. Abort it to stop waiting for the lock. It doesn’t release a lock you already hold.

Checking state

const { held, pending } = await navigator.locks.query();
// held:    [{ name: "sync", mode: "exclusive", clientId: "..." }]
// pending: requests still waiting

query() is a snapshot for debugging. By the time you act on it, it may be out of date. Never use it as “check, then take”.

Scope: who shares the lock?

  • Every same-origin window, iframe, dedicated worker, shared worker and service worker shares the same lock namespace.
  • Different origins never see each other’s locks.
  • Like other storage, locks in third-party iframes are partitioned. An iframe of your site inside another site has its own set.
  • Locks don’t survive a browser restart. They’re in memory only.
  • HTTPS only (secure contexts). On http:// the navigator.locks object isn’t there.

Patterns

1. Mutual exclusion (the basic one)

Only one tab at a time refreshes a token, runs a migration, or flushes a queue to the server.

await navigator.locks.request("token-refresh", async () => {
  const token = await readToken();
  if (isExpired(token)) await refreshToken();
});

Notice the re-check inside the lock. While you were waiting, another tab may already have done the work. “Take the lock, then check whether it’s still needed” is the safe order.

2. Leader election

Exactly one tab should own the WebSocket, run the background poller, or keep the shared cache warm.

navigator.locks.request("leader", () => {
  // We're the leader now. Never resolve: hold until this tab dies.
  startBackgroundWork();
  return new Promise(() => {});
});

Every tab calls this. One gets it right away. The rest wait in a queue. When the leader tab closes, the next tab in line becomes leader automatically. No heartbeats, no timeouts. This is the main reason to learn Web Locks.

To tell the other tabs who is leader, combine it with BroadcastChannel.

3. “Is anyone using this?” (presence)

Hold a lock named after a resource for as long as you’re using it. Anyone else can test with ifAvailable:

// Tab A: claim this cache for the whole life of the tab
navigator.locks.request(`cache:${scopeId}`, () => new Promise(() => {}));
 
// Cleanup code, anywhere:
await navigator.locks.request(`cache:${scopeId}`, { ifAvailable: true }, async (lock) => {
  if (!lock) return;        // someone's using it, leave it alone
  await deleteCache(scopeId); // nobody holds it, and we hold it now, so it's safe
});

The second half matters. Deleting while holding the lock means no tab can claim the cache in the middle of the delete.

4. Read/write lock

// Many readers at once
navigator.locks.request("settings", { mode: "shared" }, readSettings);
// One writer, waits for readers to finish, blocks new ones
navigator.locks.request("settings", { mode: "exclusive" }, writeSettings);

Deadlocks

Locks can deadlock, just like on a server. Tab A holds x and waits for y. Tab B holds y and waits for x. Both wait forever.

How to avoid it:

  • Take several locks in the same order everywhere (always x before y).
  • Prefer one coarser lock over several fine ones.
  • Use ifAvailable or a signal with a timeout when waiting forever is unacceptable:
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
await navigator.locks.request("x", { signal: controller.signal }, work);
// rejects with AbortError if it couldn't get the lock in 5 s

What Web Locks does NOT do

  • It doesn’t protect data by itself. It’s advisory. Code that doesn’t ask for the lock can still write to IndexedDB. Every writer has to use it.
  • It doesn’t cross devices or reach the server. It’s per browser profile.
  • It isn’t a transaction. If your callback writes half its data and throws, the lock is released and the half-write stays. Use IndexedDB transactions for atomic writes, and Web Locks to decide who writes.
  • It’s not supported everywhere forever. It’s in all modern browsers (Safari since 15.4). Old webviews may lack it, so check "locks" in navigator and decide on a fallback. Often the safe fallback is “do nothing destructive”.

A real-world example

A collaborative editor caches each open note’s state in IndexedDB, one cache per tab, and ties each cache to a “tab scope”. When a tab starts, it takes a lock named after its scope and holds it for its whole life (pattern 3 above). A cleanup sweeper later looks at old caches and asks for each scope’s lock with ifAvailable: true:

  • Got null → a live tab owns that cache → skip it.
  • Got the lock → no tab owns it → it’s safe to delete, and the sweeper deletes while still holding the lock.

A duplicated tab mints a new scope, because the original tab still holds the old one. In browsers without Web Locks, the sweeper never deletes anything, because it can’t prove a cache is unused.

Gotchas

  • The lock lasts until the callback’s promise settles. If your callback isn’t async and doesn’t return the promise, you release the lock immediately.
  • Re-check the condition inside the lock. Something may have changed while you waited.
  • query() is for debugging, not for logic.
  • steal is a sledgehammer. The old holder keeps running its code and only learns it lost the lock because its promise rejects.
  • An aborted signal only stops the wait. It doesn’t release a held lock.
  • HTTPS only. http://localhost counts as secure, so local dev works. A LAN IP like http://192.168.1.20 over plain HTTP doesn’t, which catches people testing on a phone.

Practice

  1. Open the same page in three tabs. Implement leader election and show “I am the leader” in exactly one. Close it and watch another tab take over.
  2. Build “only one tab refreshes the token”: a fake refresh that takes 2 s, triggered by all tabs at once. Log how many refreshes actually happen.
  3. Cause a deadlock on purpose with two locks taken in opposite orders. Fix it with a consistent order.
  4. Kill a tab from the browser’s task manager while it holds a lock. Confirm the lock is released.

Sources worth reading

  • MDN: Web Locks API, LockManager.request()
  • W3C spec: Web Locks API
  • web.dev / Chrome Developers: Web Locks API article