JWT Refresh: Solving the Multi-Tab Token Trap

RayTinkerer Novice 4h ago Updated Jul 26, 2026 28 views 14 likes 2 min read

Single-tab mutex locks are useless the moment a user opens a second browser window. If you're using an Axios interceptor with an isRefreshing flag and a promise queue, you've solved concurrency within one JS runtime, but you've completely ignored the fact that every browser tab is its own isolated memory space.

When a short-lived JWT expires and a user has four tabs open, all four tabs will simultaneously detect the 401 and fire POST /auth/refresh-token/ requests. If your backend implements strict Refresh Token Rotation, Tab B's request will invalidate the token Tab A just received, leading to a session collapse that boots the user back to the login screen.

The Memory Isolation Gap

The core issue is that let isRefreshing = false is local to the tab. Tab B has no visibility into Tab A's state. This creates a "stampede" effect:

  • Tab A: Sets isRefreshing = true → Requests Token Set 1.
  • Tab B: Sets isRefreshing = true → Requests Token Set 2 (Revokes Set 1).
  • Tab C: Sets isRefreshing = true → Requests Token Set 3 (Revokes Set 2).

The result is a race condition where only the last tab to respond survives, and everyone else gets logged out.

Implementation: Using Web Locks API

To handle this, you need cross-tab concurrency control. The Web Locks API (navigator.locks) allows different tabs from the same origin to coordinate. Instead of a local boolean, you request an exclusive lock that the browser manages across all instances of your site.

Here is a practical tutorial on how to wrap your refresh logic in a web lock:

async function refreshTokenWithLock() {
  // Request an exclusive lock named 'auth_refresh_lock'
  return await navigator.locks.request('auth_refresh_lock', async () => {
    const token = localStorage.getItem('access_token');
    const refreshToken = localStorage.getItem('refresh_token');

    // Double-check: Did another tab already refresh the token while we were waiting for the lock?
    if (isTokenStillValid(token)) {
      return token; 
    }

    try {
      const response = await axios.post('/auth/refresh-token/', { refreshToken });
      const { accessToken, newRefreshToken } = response.data;
      
      localStorage.setItem('access_token', accessToken);
      localStorage.setItem('refresh_token', newRefreshToken);
      
      return accessToken;
    } catch (error) {
      throw error;
    }
  });
}

Key Technical Gains

  • Exclusive Access: Only one tab can execute the refresh logic at a time. Other tabs will asynchronously wait for the lock to be released.
  • The "Double-Check" Pattern: Once a waiting tab finally acquires the lock, it must check localStorage first. If the token was already updated by the previous lock holder, it can simply use the new token without hitting the server.
  • Deployment Ease: This is a native browser API (supported in Chrome 69+, Firefox 96+, Safari 15.4+), so it requires zero external dependencies and no complex WebSocket signaling.

Moving the synchronization layer from the JS memory heap to the browser's lock manager turns a flaky auth flow into a robust AI workflow for any high-traffic SPA.
webdevreactjavascriptAI ProgrammingAI Coding

All Replies (4)

M
Morgan42 Novice 12h ago
I switched to LocalStorage events to sync the refresh state across tabs. Works like a charm.
0 Reply
D
DrewCoder Novice 12h ago
@Morgan42 That's a clever workaround! Did you run into any race conditions when multiple tabs tried to refresh at once?
0 Reply
L
LazyBot Intermediate 12h ago
Tried this and it still glitched out on Safari. Total headache to debug, way too overcomplicated.
0 Reply
C
CameronOwl Expert 12h ago
SharedWorkers solved this for me. Keeps the refresh logic in one place regardless of open tabs.
0 Reply

Write a Reply

Markdown supported