The token trap hit a live site when five tabs opened by a single user all tried to refresh an expired JWT at the same millisecond, flooding the backend with duplicate refresh requests and instantly invalidating the session. Every tab logged the user out, proving that a single-tab solution for token renewal no longer suffices.
Why the token trap matters
Modern single-page apps use short-lived access tokens and a long-lived refresh token. When the access token expires, the client sends a refresh request, receives a new token pair, and retries the original call. Most developers guard this flow with an in-memory flag (e.g., isRefreshing = true) or a request queue, testing it in a single tab. In the real world users keep several tabs open: a settings page, an analytics dashboard, and a few data views. When the access token expires, each tab detects the 401 error independently, each fires a refresh request, and the backend—especially when it enforces refresh-token rotation—treats the second request as a replay and revokes the whole session.
JavaScript isolation creates the problem
Each browser tab runs its own JavaScript context. Variables, timers, and in-memory flags are invisible to other tabs, even when they share the same origin. A flag that says “a refresh is already in progress” lives only inside the tab that set it. The other tabs have no way to know that the token is being refreshed elsewhere, so they all launch their own network call. The issue is not a bug in the interceptor; it is a fundamental limitation of client-side state isolation.
Web Locks API to the rescue
When a tab holds a lock, any other tab that asks for the same lock must wait until it is released.
How it works for token refresh
- Detect a 401 – Any tab that receives an unauthorized response calls
navigator.locks.request('auth_token_refresh_lock', async lock => { … }). - Acquire the lock – If no other tab holds the lock, the current tab proceeds; otherwise it pauses until the lock is free.
- Refresh once – The lock-holder sends the refresh request, stores the new access token and a timestamp in
localStorage, and then releases the lock automatically when the callback finishes. - Skip duplicate work – When a waiting tab finally gets the lock, it reads the timestamp from
localStorage. If the token was refreshed within a configurable window (e.g., the last few seconds), the tab skips the network call and updates its in-memory token fromlocalStorage. - Handle crashes – If a tab crashes or is closed while holding the lock, the browser releases the lock, allowing another tab to retry the refresh.
Benefits at a glance
- Zero redundant network calls – Only the first tab talks to the backend.
- No session destruction – Refresh-token rotation sees a single use, keeping the session alive.
- Graceful recovery – Browser-managed lock release prevents deadlocks if a tab disappears.
Implementation checklist
- Wrap the refresh logic in your Axios (or fetch) interceptor with a lock request.
- Store the refreshed token and a millisecond timestamp in
localStorage(orsessionStorageif you prefer per-session data). - When a lock is granted, compare the stored timestamp to
Date.now(). If the difference is below your threshold, read the token from storage instead of calling the backend. - Make sure the interceptor updates request headers with the token retrieved from storage before retrying the original API call.
- Test the flow with multiple tabs, simulating network latency to verify that only one refresh request ever reaches the server.
What could go wrong
What to watch for next
Takeaway
Treat each browser tab as a node in a tiny distributed system. By using the Web Locks API to serialize JWT refreshes, you eliminate duplicate calls, protect refresh-token rotation, and keep users logged in across all their open tabs.
