Using the Web Locks API to stop duplicate work across browser tabs
Open your app in three tabs and every timer you wrote runs three times. The token refresh fires three times, and the second and third calls fail because the first one already rotated the refresh token. The background sync loop uploads the same queue three times. Nobody notices until a user reports a logout that “just happens sometimes”.
The Web Locks API fixes this with one call, and it has been available in every major browser for years.
What navigator.locks actually does
A lock is a name plus a callback. The browser guarantees only one holder per name at a time, across every tab, window, and worker on the same origin in the same browser profile.
await navigator.locks.request("token-refresh", async () => {
const token = await refreshToken();
saveToken(token);
});The lock is held until the promise your callback returns settles. If it throws, the lock is released. If the tab is closed or crashes, the lock is released. That last part is the reason to use this instead of a localStorage flag. I’ve written the flag version. A tab dies mid-refresh, the flag never clears, and every other tab waits forever.
Requests queue in order. Tab two and tab three wait until tab one finishes, then run one after the other.
Single-flight token refresh
Queuing alone isn’t enough here. Tabs two and three shouldn’t refresh again, they should notice that tab one already did. Check inside the lock:
async function getFreshToken(): Promise<string> {
return navigator.locks.request("token-refresh", async () => {
const current = readToken();
if (current && !isExpiringSoon(current)) return current.value;
const next = await refreshToken();
saveToken(next);
return next.value;
});
}Every caller goes through the lock, and only the first one does network work. The others read what the first one saved. The check-then-act has to live inside the callback. If you check before requesting the lock, you’ve rebuilt the race you were trying to remove.
Leader election for background work
Sometimes you don’t want everyone to take a turn. You want exactly one tab running the sync loop. Request the lock and never let the callback finish:
navigator.locks.request("sync-leader", async () => {
await runSyncLoop(); // holds the lock for as long as the loop runs
});Every tab makes this call. One becomes leader and the rest queue. When the leader tab closes or crashes, the browser releases the lock and the next tab in line takes over. No heartbeat, no timeout, no stale flag.
If followers should skip the job instead of waiting, pass { ifAvailable: true }. Your callback gets null when someone else holds the lock, and you return. That fits one-off work like “flush the queue now”. It’s the wrong fit for leadership, because a follower that gave up never comes back.
Where it bites
Deadlocks are possible. Request lock A then B in one tab and B then A in another, and both hang. Keep one lock per resource and avoid nesting.
steal: true takes the lock from the current holder and rejects that holder’s promise with an AbortError. I’ve never needed it outside of recovery tooling.
You can’t combine signal with ifAvailable or steal; the browser throws. Use signal with an AbortController when you want a request to give up after a timeout instead of queuing forever.
Scope is the other limit. Locks are per origin and per browser profile. A second device, a second browser, or a private window doesn’t see your lock. If two devices can run the same job, you still need a server-side guard. Web Locks only fixes the many-tabs case.
navigator.locks.query() returns held and pending locks. It’s useful when you’re debugging a hang and want to see who is waiting on what.
Testing it with Playwright
You can’t test this with one page. Playwright gives you multiple pages in one browser context, and they share an origin, so they share locks:
test("only one tab refreshes the token", async ({ context }) => {
let refreshCalls = 0;
await context.route("**/auth/refresh", async route => {
refreshCalls++;
await new Promise(r => setTimeout(r, 300));
await route.fulfill({ json: { token: "new", expiresIn: 3600 } });
});
const a = await context.newPage();
const b = await context.newPage();
await Promise.all([a.goto("/"), b.goto("/")]);
await Promise.all([
a.evaluate(() => window.getFreshToken()),
b.evaluate(() => window.getFreshToken()),
]);
expect(refreshCalls).toBe(1);
});The 300ms delay matters. Without it the first refresh can finish before the second tab asks, and you’d pass for the wrong reason. Take the lock out of the code and this test fails with refreshCalls at 2, which is how you know it’s testing something.