10LOC
Web Platformadvanced

The Web Locks API to coordinate work across browser tabs

Published October 6, 2026

export const getFreshAuthToken = () =>
  navigator.locks.request("auth-token-refresh", async () => {
    const expiresAt = Number(localStorage.getItem("auth-token-expires-at") ?? 0);
    if (Date.now() < expiresAt) return localStorage.getItem("auth-token")!;

    const res = await fetch("/api/refresh-token", { method: "POST" });
    const { token, expiresInMs } = await res.json();
    localStorage.setItem("auth-token", token);
    localStorage.setItem("auth-token-expires-at", String(Date.now() + expiresInMs));
    return token;
  });

What

navigator.locks.request(name, callback) runs callback only after acquiring an exclusive, named lock that's shared across every same-origin tab, worker, and window — not just within one page. getFreshAuthToken uses it so that when five tabs all discover their token expired at once, exactly one of them calls the refresh endpoint.

Why it matters

A token refresh guarded only by an in-memory flag (let isRefreshing = false) only protects a single tab. Open the same app in three tabs and all three can independently decide their token is stale and fire three simultaneous refresh requests — wasted network calls at best, a revoked-token race at worst if the server invalidates the previous refresh token on each use.

localStorage alone doesn't fix it either: reading a value, deciding it's stale, and writing a new one is three separate steps, and nothing stops two tabs from both reading "stale" before either writes "fresh." The Web Locks API adds the one thing that was missing — real mutual exclusion, scoped to a name, honored across every browsing context on the origin, with no server involvement and no polling.

How it works

  • navigator.locks.request("auth-token-refresh", callback) queues callback behind any other pending or held request for that same lock name, across all tabs. It returns a promise that resolves with whatever callback returns.
  • Inside the callback, the expiry check reads from localStorage, which — unlike sessionStorage — is shared across tabs on the same origin. That's what lets a tab that lost the race see the fresh token the winning tab just wrote.
  • The first tab to acquire the lock finds no valid cached token, so it actually calls /api/refresh-token and writes the result.
  • Every other tab's call to getFreshAuthToken() blocks inside navigator.locks.request() until the first tab's callback resolves. When they finally run, Date.now() < expiresAt is now true, so they return the cached token immediately without ever touching the network.

Gotchas

  • The lock is released automatically when the callback's returned promise settles — including on rejection. Don't wrap navigator.locks.request() in your own try/catch that swallows the error; let it propagate so callers know the refresh failed.
  • Locks are per-origin, not per-tab-group — a lock named "auth-token-refresh" is the same lock in every tab of that origin, including ones in different windows. Pick specific, collision-free names.
  • Requires a secure context (HTTPS or localhost). There's no synchronous "is this lock held" check outside of LockManager.query(); if you need a non-blocking attempt, pass { ifAvailable: true } and check whether the callback received null.

Related

  • BroadcastChannel — good for announcing that a refresh happened, but doesn't by itself prevent multiple tabs from starting the same work; often paired with the Web Locks API rather than used instead of it.
  • SharedWorker — a heavier alternative for centralizing cross-tab state in one shared execution context, instead of coordinating access with locks.