Web Locks API: Coordinate Work Across Browser Tabs

The Web Locks API is a mutex shared by every tab, iframe and worker on the same origin. Call navigator.locks.request(name, callback) and the callback runs only when no other context holds that name; the lock is released when the callback’s promise settles. Two jobs it handles well are refreshing a token once across tabs and electing one leader tab.

It has been Baseline across Chrome, Firefox and Safari since March 2022, according to MDN, and it needs a secure context (HTTPS, or localhost during development). It replaces the home-made versions built from localStorage timestamps and storage events, which cannot make two tabs wait for each other.

How does navigator.locks.request work?

You pass a name and a callback. The browser queues your request behind anyone already holding that name, then calls your callback with a Lock object. Whatever the callback returns decides how long you hold it: return a promise and the lock stays held until that promise settles. Return anything else and it is released straight away.

request() itself returns a promise for the callback’s result, so the locked section reads like an ordinary function call:

const count = await navigator.locks.request('visit-counter', async () => {
  const next = Number(localStorage.getItem('visits') ?? '0') + 1;
  localStorage.setItem('visits', String(next));
  return next;
});

If the callback throws, the lock is still released and the promise rejects with that error. Every behaviour described below was run in Chromium, Firefox and WebKit through Playwright 1.61, and the three engines agreed.

The trap is a callback that starts async work without returning it. In that run, a callback that set a timer and returned nothing let the next queued request in before the timer fired. The browser has no idea about work you did not hand back to it, so async callbacks and a return on every promise are the habit to build.

Names are plain strings scoped to the origin (strictly, to the storage bucket in the W3C spec). A name starting with - is reserved and rejects with NotSupportedError.

How do you refresh a token once across tabs?

A laptop wakes, five tabs notice an expired access token at the same moment, and five refresh requests race each other. If the server rotates refresh tokens, four of those requests present a token that has already been used, and depending on the server’s reuse rules that can log the user out.

Put the refresh behind a lock and re-check the token once you are inside it:

type Token = { value: string; expiresAt: number };

const STORAGE_KEY = 'auth-token';

function readToken(): Token | null {
  const raw = localStorage.getItem(STORAGE_KEY);
  return raw ? (JSON.parse(raw) as Token) : null;
}

function isFresh(token: Token | null): token is Token {
  return token !== null && token.expiresAt - Date.now() > 30_000;
}

export async function getToken(): Promise<string> {
  const cached = readToken();
  if (isFresh(cached)) return cached.value;

  return navigator.locks.request('auth-refresh', async () => {
    // Another tab may have refreshed while this one waited in the queue.
    const current = readToken();
    if (isFresh(current)) return current.value;

    const res = await fetch('/api/token/refresh', {
      method: 'POST',
      credentials: 'include',
    });
    if (!res.ok) throw new Error(`Token refresh failed: ${res.status}`);

    const next = (await res.json()) as Token;
    localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
    return next.value;
  });
}

The second readToken() does the real work. Without it the lock only serialises the five refreshes instead of collapsing them into one. With three tabs calling getToken() at once against a local server that counted requests, we saw one refresh call and the same token in all three tabs.

If the token lives in memory rather than localStorage, the lock still stops the race, but the waiting tabs need another way to receive the result. A BroadcastChannel message from the tab that did the refresh can carry it.

How do you elect a leader tab?

Some work should happen once per browser, not once per tab: one WebSocket, one polling loop, one sync job writing to IndexedDB. The pattern is a lock that the leader never releases. Every other tab queues for it, and when the leader’s tab closes the browser releases the lock and grants it to the next tab in line.

type Cleanup = () => void;

export function runAsLeader(name: string, start: () => Cleanup): () => void {
  const controller = new AbortController();

  navigator.locks
    .request(name, { signal: controller.signal }, () => {
      const stop = start();
      // Hold the lock until this tab resigns or closes.
      return new Promise<void>((resolve) => {
        controller.signal.addEventListener(
          'abort',
          () => {
            stop();
            resolve();
          },
          { once: true },
        );
      });
    })
    .catch((err: unknown) => {
      // Aborted while still queued: this tab was never the leader.
      if (err instanceof DOMException && err.name === 'AbortError') return;
      throw err;
    });

  return () => controller.abort();
}

Call it with something like runAsLeader('live-prices', () => { const ws = new WebSocket(url); return () => ws.close(); }) and keep the returned function for when the component unmounts or the user logs out.

The AbortSignal does two jobs here. While the request is still queued, aborting it drops the request and the promise rejects with AbortError, which the catch swallows. Once the lock is granted, the signal has no effect on the lock itself, so the code listens for abort and resolves the held promise by hand. In all three engines, resigning handed leadership to the waiting tab, and closing the leader’s page did the same.

Followers usually still want the data. Have the leader post it on a BroadcastChannel and every other tab listen. The lock API has no way to send data between tabs, so the two work as a pair.

ifAvailable, signal and steal: which option do you need?

ifAvailable: true asks for the lock without queuing. If someone holds it, your callback runs with null instead of a Lock, so check for that before doing anything. It suits “run this cleanup if nobody else is already on it”.

signal is for waiting with a deadline. Pass AbortSignal.timeout(2000) and the request rejects if it is not granted in time, with the signal’s reason: a TimeoutError for AbortSignal.timeout(), an AbortError for a plain controller.abort(). Check for both if you use both. You cannot combine signal with ifAvailable (there is nothing to wait for), and you cannot combine steal with ifAvailable. Both combinations reject with NotSupportedError; the message text differs by engine.

mode: 'shared' gives you a readers-writer lock. Any number of shared holders can run together, and an exclusive request waits until they have all finished.

steal: true releases whoever holds the name and grants it to you immediately, ahead of the queue. The previous holder’s request() promise rejects with AbortError. Its callback keeps running: the test callback that lost its lock still finished its 300 ms of work afterwards. MDN’s warning that the two “may clash” is accurate. Treat steal as an escape hatch for recovering from a stuck holder, not something to call routinely.

How do you see which tab holds a lock?

navigator.locks.query() returns a snapshot with held and pending arrays. Each entry has the lock’s name, its mode and a clientId identifying the frame or worker that asked for it. Logging that from the console is the quickest way to find which tab is sitting on a lock. It is a snapshot only, so do not build logic on it.

The spec admits that deadlocks are possible: tab A holds x and waits for y while tab B does the reverse. Its advice is to acquire multiple locks in a fixed order. A callback whose promise never settles holds its lock until the tab closes, which is exactly what the leader pattern relies on and exactly what you do not want anywhere else.

Where does the Web Locks API stop working?

Locks are per origin and per browser profile. They do nothing between two devices, between a normal window and a private one, or between your site and a third-party iframe on another origin. They are no substitute for a server-side lock or an idempotency key; if two devices can submit the same order, the server still has to deal with it.

There is also a server-side version now. Node.js added navigator.locks and worker_threads.locks in v24.5.0, marked Stability 1 (Experimental) in the Node.js globals docs. They coordinate threads within one process only: on Node 24.12 a worker’s ifAvailable request got null while the main thread held the lock, but separate processes under cluster or PM2 share nothing. If you are weighing threads against processes for that reason, our comparison of worker threads and child processes covers the trade.

What we would do

Use Web Locks for any client-side job that must run once per browser: token refresh, a single shared connection, IndexedDB migrations, background sync. Use an exclusive lock with a re-check inside it, plus BroadcastChannel when followers need the result. Keep steal for recovery code.

We would not rely on it for anything that has to hold across devices or browsers. And the leader’s WebSocket still needs closing in pagehide if you want the page restored from the back/forward cache, which our bfcache guide covers.

We build React front ends where several tabs, workers and a live connection all have to agree on state. If your app runs the same job in every open tab, that is the kind of problem our React development work takes on.

Need this built properly?

Get in touch