Skip to content
Back to the Lab

Coordinating Browser Tabs With Web Locks

A deep technical guide to the Web Locks API, covering why multiple tabs have always needed coordination, requesting a lock with automatic release, exclusive versus shared mode, ifAvailable and signal options, introspecting locks with query, a leader election example, pairing locks with the Origin Private File System, and browser support.

A dark Egnworks banner representing one browser tab holding a lock while others wait in queue.

A page open in several tabs at once has always had a coordination problem the moment some piece of work should genuinely only happen once. Every tab runs its own independent copy of the page’s JavaScript, with no built-in way for one tab to know whether another tab already started the same sync job, opened the same file, or is mid-way through the same expensive setup. Storage events and BroadcastChannel let tabs talk to each other, but neither one gives a tab an actual, exclusive claim over a piece of work the way a lock does. The Web Locks API adds that missing primitive directly.

Why Multiple Tabs Have Always Needed Coordination#

Opening the same origin in several tabs is extremely common, a user rarely thinks twice about it, but from the application’s perspective every tab is a fully independent execution context that happens to share the same storage. A background sync job, a websocket connection to a server, or exclusive access to a resource like a single sync access handle on an Origin Private File System file all become genuinely dangerous to run redundantly, duplicate sync requests racing each other, several tabs each believing they own the same websocket, or two tabs attempting to open the same exclusive file handle and one of them simply failing. Coordinating this correctly used to mean building a hand rolled protocol on top of localStorage events or a BroadcastChannel, with all the edge cases a homemade mutex built on message passing tends to accumulate.

Requesting a Lock#

navigator.locks.request() takes a lock name and a callback, and only runs that callback once the named lock has actually been acquired.

await navigator.locks.request("sync-job", async (lock) => {
  await syncWithServer();
});

If another tab already holds a lock with the same name, this call queues behind it rather than running immediately, and resolves only once it becomes that tab’s turn to hold the lock.

The Lock Is Released Automatically#

There is no explicit unlock call. The lock is held for exactly as long as the callback’s returned promise is pending, and released the instant that promise settles, whether it resolves or rejects.

await navigator.locks.request("sync-job", async () => {
  await step1();
  await step2();
  // lock releases here, whether this throws or completes normally
});

This removes an entire category of bug a manual lock API would otherwise invite, a tab that crashes or throws partway through holding the lock cannot leave it stuck in a held state forever, since the browser itself is responsible for releasing it based on the callback’s promise rather than trusting application code to call an explicit release function on every code path, including error paths.

Exclusive Versus Shared Mode#

The default mode is exclusive, only one holder at a time, anywhere. shared mode allows multiple simultaneous holders, which fits a readers-writer pattern where any number of tabs can safely read something at once, but a write needs to wait until no reader currently holds the lock.

navigator.locks.request("cache-file", { mode: "shared" }, async () => {
  await readFromCache();
});

navigator.locks.request("cache-file", { mode: "exclusive" }, async () => {
  await writeToCache();
});

An exclusive request for cache-file waits until every current shared holder releases before it is granted, giving a writer a real, eventual turn rather than being blocked by readers indefinitely, since a lock request only ever queues behind holders that were already granted at the time it was made.

Trying Without Waiting: ifAvailable#

Sometimes the correct behavior is not to wait in line at all, only to act if the lock happens to be free right now. ifAvailable does exactly that, the callback runs with null instead of a lock object if the request cannot be granted immediately.

navigator.locks.request("sync-job", { ifAvailable: true }, async (lock) => {
  if (!lock) {
    return; // another tab is already syncing, do nothing
  }
  await syncWithServer();
});

This fits a periodic background task well, if another tab is already handling this cycle’s sync, there is no reason for this tab to queue up and run the same sync again a few seconds later once its turn finally comes.

Giving Up on a Lock: signal#

An AbortSignal passed through signal lets a pending, still queued request be cancelled before it is ever granted, useful for a lock request tied to a timeout or to a component that might unmount before its turn arrives.

const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);

navigator.locks
  .request("sync-job", { signal: controller.signal }, async () => {
    await syncWithServer();
  })
  .catch((error) => {
    if (error.name !== "AbortError") throw error;
  });

Aborting only affects a request still waiting in the queue, once a lock has actually been granted and the callback is running, the same abort signal does not interrupt it mid-flight.

Introspecting Held and Pending Locks With query()#

navigator.locks.query() returns a snapshot of every held and pending lock for the current origin, useful for debugging a coordination issue without guessing at what state every tab currently believes itself to be in.

const state = await navigator.locks.query();
console.log(state.held, state.pending);

Each entry reports the lock’s name, its mode, and a client identifier, which is enough to see, for example, that three separate tabs are all queued behind the same exclusive lock when only one was actually expected to be requesting it.

A Practical Example: Leader Election for a Sync Job#

A page open in several tabs that all run a periodic background sync can use a single named lock to guarantee only one of them actually performs the sync on any given cycle.

async function runSyncCycle() {
  await navigator.locks.request(
    "app-sync-leader",
    { ifAvailable: true },
    async (lock) => {
      if (!lock) return;
      await syncPendingChanges();
    }
  );
}

setInterval(runSyncCycle, 30000);

Every tab calls this on the same interval, but only the tab that happens to acquire the lock on a given cycle does any real work, the rest see ifAvailable return null and skip the cycle entirely, without any tab needing to know how many other tabs exist or coordinate directly with them.

Pairing Locks With the Origin Private File System#

The Origin Private File System only allows one FileSystemSyncAccessHandle to be open on a given file at a time, which is exactly the kind of exclusive resource a Web Lock exists to protect. Wrapping access to a shared OPFS file in a lock named after that file ensures only one tab attempts to open a sync access handle on it at once, turning what would otherwise be a race that fails outright for the losing tab into an orderly queue instead.

await navigator.locks.request("app-db-file", async () => {
  const root = await navigator.storage.getDirectory();
  const fileHandle = await root.getFileHandle("app.db");
  const accessHandle = await fileHandle.createSyncAccessHandle();
  // use accessHandle, then close it before this callback returns
  accessHandle.close();
});

What Locks Do Not Coordinate#

Web Locks are scoped to a single origin and coordinate tabs, windows, and workers sharing that origin, they say nothing about a second device or a server side process also touching the same underlying data. A user with the same account open on their phone and their laptop still needs server side coordination for anything that spans devices, a Web Lock only ever solves the same-origin, same-browser-profile case.

Browser Support#

The Web Locks API is supported in Chrome, Edge, Firefox, and Safari, and has been broadly available for long enough to use directly in a project targeting current browser versions without a fallback path.

Conclusion#

The Web Locks API gives multiple tabs, windows, and workers on the same origin the one primitive a hand rolled coordination protocol always struggled to get right, an actual exclusive or shared claim over a named resource, automatically released regardless of how the holding code exits. ifAvailable turns a queued wait into an optional check, signal lets a pending request be abandoned, and query() makes the current state of every lock inspectable rather than something to infer from application logs scattered across several tabs.

References#

MDN: Web Locks API

MDN: LockManager.request()

MDN: LockManager.query()

Can I Use: Web Locks API