Fixing Token Refresh Races With the Web Locks API
Fix token refresh races across tabs with the Web Locks API, navigator.locks.request, and a token re-check to prevent logout loops.
When several tabs share one rotating refresh token, the first tab’s refresh call can invalidate the token the other tabs are about to send. Their refreshes then fail, and the user can end up logged out of every tab at once. The fix is to wrap the refresh in navigator.locks.request so exactly one tab performs it while the rest wait, then reuse the token it stored.
If you’re chasing a bug report that says “I got logged out without doing anything” and it never reproduces on your machine, ask how many tabs the reporter had open. A single-tab refresh queue in your interceptor is correct as far as it goes, but it cannot see this race.
This article walks the sequence that produces the logout, why a localStorage flag is not a reliable fix for it, and the three pieces of the fix: the lock, the re-check inside it, and the interceptor wiring.
Key Takeaways
- When multiple tabs refresh a rotated token simultaneously, only the first call succeeds; the server invalidates the token every other tab is about to send, logging the user out everywhere.
navigator.locks.requestgives every same-origin tab, iframe, and worker a shared mutex, and it is Baseline Widely available, so no fallback code is needed.- A
localStorageflag is not a lock: there is no atomic compare-and-set, and a flag written by a crashed tab stays stuck while a Web Lock releases automatically. - Waiting tabs must re-check the stored token inside the lock callback and gate on the token’s own expiry, not on a “refreshed recently” timestamp.
- Done correctly, any number of tabs hitting an expired token produces exactly one network refresh.
Why Does a Refresh Token Fail Across Multiple Tabs?
The race needs four ingredients: short-lived access tokens, a refresh endpoint, refresh token rotation, and more than one tab. All four are common. RFC 9700, the OAuth 2.0 Security Best Current Practice, leaves public clients two choices for refresh tokens: tie each one to the client that was issued it, or hand out a new one on every use. That makes rotation the default posture for SPAs.
The sequence: the user has three tabs open and the access token expires. Each tab’s next request gets a 401, and each tab’s interceptor independently calls the refresh endpoint. Tab A’s call lands first and succeeds; if the server rotates and invalidates the previous refresh token on use, Tabs B and C are now sending a dead credential. Their refreshes fail, their error handlers treat a failed refresh as a terminal auth failure, and the user is redirected to login mid-task.
In a session replay this bug has a distinctive signature: a redirect to the login screen with no user interaction before it, occurring in every one of the user’s open tabs within the same second. That signature, not anything in the console, is what identifies it.
Why Can’t Per-Tab State or a localStorage Flag Fix It?
Your interceptor’s isRefreshing flag and promise queue live in one tab’s JavaScript memory. Tabs do not share memory, so Tab B never sees Tab A’s flag. Cross-tab coordination needs a browser-level primitive.
The traditional workaround, a “refresh in progress” flag in localStorage, is not a lock. The Web Storage API gives you getItem and setItem but no atomic compare-and-set, so two tabs can both read that no refresh is in progress and both begin one before either write lands. The flag also fails in the opposite direction: if the tab that set it crashes or closes mid-refresh, the flag stays set forever and every surviving tab waits on a refresh that will never finish. A Web Lock is released by the browser the moment its holder’s document goes away, which is exactly the failure that wedges a hand-rolled flag.
How Do You Wrap the Refresh in navigator.locks.request?
The Web Locks API gives every tab, iframe, and worker on an origin a shared mutex. With navigator.locks.request(name, callback), only one holder of a given name runs its callback at a time, and the browser drops the lock as soon as the promise that callback returns settles, whether it resolves or rejects. There is no unlock call to forget and no leaked lock on a failed fetch. Every major engine has shipped the API since Safari 15.4 added it in March 2022, which is why MDN rates it Baseline Widely available, so no feature detection or fallback branch is warranted.
async function refreshTokenAcrossTabs() {
return navigator.locks.request("token-refresh", async () => {
const existing = getUsableToken();
if (existing) return existing; // another tab already refreshed
const res = await fetch("/auth/refresh", {
method: "POST",
credentials: "include",
});
if (!res.ok) throw new Error("refresh_failed");
const { accessToken, expiresAt } = await res.json();
writeToken(accessToken, expiresAt);
return accessToken;
});
}
readToken and writeToken are deliberately abstract: where tokens live is a separate security decision this article does not make, and the lock works the same regardless.
The Re-Check: Test the Token, Not the Clock
The step most implementations skip is the re-check inside the lock callback: a tab that waited for the lock should first test whether the stored token is now valid, and if it is, return it without a second network call. Three tabs queue on the lock; the first does the round trip, the second and third acquire it afterwards, find a usable token, and return immediately. One network refresh, regardless of tab count.
Gate that re-check on whether the stored token is actually usable, not on how recently a timestamp was written. A wall-clock “refreshed in the last 5 seconds” gate breaks two ways: a refresh slower than the window makes waiting tabs wrongly conclude nothing happened and fire duplicates, and a skewed client clock invalidates the comparison in either direction. The token’s own expiry cannot lie about itself.
const SKEW_MS = 30_000; // tolerate modest clock drift
function getUsableToken() {
const stored = readToken(); // { token, expiresAt } or null
if (!stored) return null;
return stored.expiresAt - SKEW_MS > Date.now() ? stored.token : null;
}
Wiring the Lock Into a 401 Interceptor
The interceptor’s job is unchanged: catch the 401, obtain a fresh token, replay the original request once. The only difference is that the refresh call is now refreshTokenAcrossTabs(), so the serialization spans every tab.
api.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config;
if (error.response?.status !== 401 || original._retry) {
return Promise.reject(error);
}
if (original.url?.includes("/auth/refresh")) {
await logout(); // the refresh itself failed: session is gone
return Promise.reject(error);
}
original._retry = true;
try {
const token = await refreshTokenAcrossTabs();
original.headers.Authorization = `Bearer ${token}`;
return api(original);
} catch (refreshError) {
await logout();
return Promise.reject(refreshError);
}
}
);
The _retry guard prevents loops, and a 401 from the refresh endpoint itself means logout, not retry. From a waiting tab’s perspective the full path is: 401, queue on the lock, acquire it, find a valid token, return with zero network calls, replay the original request.
Edges Worth Knowing
- Web Locks requires a secure context;
http://localhostqualifies as a potentially trustworthy origin. - The spec’s termination rules release a document’s locks on unload, so a lock never survives reload or navigation and is never durable state.
- Keep the critical section to the refresh alone; anything else you await inside it blocks every tab.
- Never request the same lock inside its own callback: the inner request queues behind the outer hold and hangs silently, forever.
- Shared mode,
ifAvailableleader election, andstealexist for other jobs; the token refresh needs none of them. - BroadcastChannel tells other tabs that something happened; a lock stops them all doing it at once.
Conclusion
The random-logout bug is a distributed-systems race running on the user’s machine, and the browser ships the mutex that ends it. Wrap your refresh in navigator.locks.request, make the first line of the callback a token-validity check, and route your 401 handler through it. Reproduce the bug first with several tabs and a short token lifetime, then apply the lock and watch N refresh calls collapse to one.
FAQs
Can BroadcastChannel replace the Web Locks API for cross-tab token refresh?
No. BroadcastChannel is a messaging transport, not a mutex: it can announce that a refresh happened, but nothing stops two tabs from both starting one before either message arrives, the same read-then-act race a localStorage flag has. Use navigator.locks.request to serialize the refresh, and add BroadcastChannel afterwards only if you want to push the new token to listening tabs.
How do I add a timeout to a navigator.locks.request call?
Pass an AbortSignal through the signal option. Abort it while the request is still sitting in the queue and the promise rejects with an AbortError, so AbortSignal.timeout gives you a deadline for the wait. After the lock is granted the signal stops having any effect, so a timeout cannot cut short a callback that is already running. Pairing signal with steal or ifAvailable rejects with a NotSupportedError, so pick one strategy per request.
Does the Web Locks API work in web workers and service workers?
Yes. The spec exposes LockManager to both Window and Worker contexts, so dedicated workers, shared workers, and service workers can call navigator.locks.request. Every context on the same origin shares one lock manager, meaning a worker requesting the 'token-refresh' lock queues against tabs requesting the same name. The pattern therefore stays correct even if part of your auth logic runs off the main thread.