DEV Community

Cover image for Five tabs open, one refresh token — the race nobody noticed
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

Five tabs open, one refresh token — the race nobody noticed

Reveals why multi-tab apps trigger sudden logouts

A user reports that they keep getting logged out. Not immediately — after a while, randomly, always mid-task. You can't reproduce it. You check the server logs and find something stranger than a bug report: the same refresh token, submitted four times in the same eleven-millisecond window, from the same user, same IP, same session. The server does what any reasonable auth server does with a reused refresh token — it assumes theft and revokes the whole session.

The user didn't do anything wrong. They just had four tabs open.

Why one user becomes four requests

Your access token expires. Every tab that's currently open holds its own copy of the JavaScript running your app, and every one of those copies is watching the same clock. The moment the token goes stale, each tab's fetch wrapper notices independently and does the sensible thing: call the refresh endpoint before retrying the failed request.

Four tabs, four independent "sensible things," at nearly the same instant. The server sees four refresh attempts for one token. Depending on how strict your rotation policy is, the second one in either succeeds and burns the token for the other three, or the server flags it as replay and kills the session outright. Either way, the user gets logged out for the crime of having your app open twice.

The fix that looks right and isn't

The instinct is to reach for localStorage as a shared flag, since it's the one thing every tab on the origin can already see:

// Looks like a mutex. Isn't one.
async function refreshIfNeeded() {
  if (localStorage.getItem('refreshing') === 'true') {
    await waitForFlagToClear();
    return;
  }
  localStorage.setItem('refreshing', 'true');
  await refreshToken();
  localStorage.removeItem('refreshing');
}
Enter fullscreen mode Exit fullscreen mode

This passes every manual test you run by clicking around in two tabs, because you're slower than the bug. The problem is that "check the flag, then set the flag" is two separate operations, and nothing stops two tabs from both running the check before either of them runs the set. localStorage reads and writes aren't atomic across tabs — there's no lock on the lock. You've built the exact race you were trying to prevent, just with extra steps and a waitForFlagToClear polling loop bolted on.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

The actual fix: navigator.locks

The Web Locks API gives you what localStorage was pretending to be: a real, origin-scoped mutex that the browser itself arbitrates, shared across every tab, iframe, and worker on that origin.

async function refreshIfNeeded() {
  return navigator.locks.request('refresh-token', async (lock) => {
    // Only one tab's callback runs at a time for this lock name.
    // Everyone else queues here until it's their turn.
    if (tokenIsStillValid()) return; // by the time we got the lock, someone else already refreshed
    await refreshToken();
  });
}
Enter fullscreen mode Exit fullscreen mode

navigator.locks.request(name, callback) returns a promise that resolves once your callback's own returned promise settles — and the lock is held for exactly that long. Call it from all four tabs at once and the browser queues three of them. The first one through does the actual refresh; by the time the second tab gets its turn, the token check at the top short-circuits and it does nothing. No polling, no flag to forget to clear, no window where two tabs both think they're first.

The mode option is where it earns its name: exclusive (the default) allows one holder, shared allows many readers at once as long as no writer is waiting — the same read/write distinction you'd reach for with any mutex. Pass { ifAvailable: true } if you want to try the lock without waiting, or a signal if the wait itself needs to be cancellable.

Where else this shows up

Token refresh is the clearest case, but the pattern is "N tabs, one shared resource, exactly-once semantics" — and that shows up more than you'd think:

  • IndexedDB migrations. A schema upgrade that runs once per version, not once per open tab.
  • Single-flight cache fills. Five tabs all miss a cache entry at once; you want one network request, not five.
  • Cross-tab leader election. One tab should own a websocket or a polling interval; the rest should just listen. Hold a lock for as long as the tab is "leader," and release it (close the tab, or explicitly) to let another tab take over.

Each of these is a version of the same bug: code that's correct for one execution context quietly breaks the moment a user does something completely reasonable, like opening a second tab.

What this isn't

navigator.locks doesn't send data between tabs — that's BroadcastChannel's job, and the two pair well together (grab the lock, do the work, broadcast the result to whoever's waiting). Locks answer "who goes first," not "who else needs to know." Confusing the two gets you either a mutex trying to carry a payload, or a message channel trying to enforce mutual exclusion — both are the wrong tool wearing the other one's hat.

Browser support

navigator.locks shipped in Chrome 69 (2018), Firefox 96 (2022), and Safari 15.4 (2022) — all three major engines have had it for years, so it's safe to reach for directly rather than feature-detecting a fallback.

🧠 Test yourself

Think it clicked? Take the 8-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

The takeaway

If your app has ever logged a user out for no reason they could explain, check whether the fix you'd reach for — a flag in localStorage, a "just add a delay" hack — is really a mutex, or just something that looks like one until two tabs hit it at the same millisecond. navigator.locks.request() is the browser handing you a real one, for free, scoped exactly where you need it: across every tab your user happens to have open.

Have you shipped a bug that only showed up with multiple tabs open? What was it, and how long did it take to reproduce?


Thanks for reading! Let's stay connected:

Top comments (4)

Collapse
 
nazar-boyko profile image
Nazar Boyko

I might be missing it, but if the access token lives in memory per tab, the lock alone doesn't hand tabs B and C the fresh token after tab A refreshes. Is that where BroadcastChannel comes in like you hint near the end, or do you keep the token somewhere all tabs can read?

Collapse
 
parsajiravand profile image
Parsa Jiravand

You're absolutely right. The lock only coordinates who performs the refresh; it doesn't synchronize the resulting token between tabs.

If the access token is kept in memory per tab, I'd pair the lock with BroadcastChannel: Tab A acquires the lock and refreshes, then broadcasts the new token so B and C can update their in-memory state. The lock answers "who refreshes?" while the channel answers "how do the other tabs learn about the result?"

Another option is shared persistent state, but I generally prefer keeping short-lived access tokens out of persistent browser storage when possible.

That's an important distinction I should have made more explicit in the article. The current example demonstrates the race prevention, but a complete multi-tab auth implementation needs both coordination and state propagation.

Collapse
 
peterbuildssecure profile image
Peter

navigator.locks is the right fix for coordinating tabs, but I’d keep it out of the server’s security assumptions. It does not cover another browser profile, another device, or a client that crashes halfway through the refresh.

The refresh endpoint still needs to consume the old token atomically. A useful shape is a compare-and-swap on the stored token hash so only one request can create the successor. If benign concurrent retries must be tolerated, require an idempotency key bound to the session/device and return the same rotation result only for that key within a very narrow window. A different key or reuse after the window should trigger the token-family reuse policy.

The regression test should send two refreshes simultaneously and prove they cannot create two valid successor branches, then replay the old token after the tolerance window and prove it is rejected.

That leaves the browser lock as a reliability optimization and keeps rotation correctness on the server.

Collapse
 
parsajiravand profile image
Parsa Jiravand

Completely agree. navigator.locks should be treated as a client-side coordination mechanism, not part of the server's security model.

The server still needs to enforce the rotation invariant atomically because a refresh request can come from somewhere that never participates in the browser lock—or from a client that crashes, retries, or behaves unexpectedly.

The compare-and-swap approach is a good way to express that invariant: two concurrent requests shouldn't be able to turn the same refresh-token state into two independent successors.

I also like the regression test you described. Testing simultaneous refreshes and then replaying the old token verifies the security property itself rather than just proving that the browser-side lock works.

Thanks for adding that perspective. It makes the distinction between reliability on the client and security on the server much clearer.