Handling Token Expiry in SPAs Without Losing the User's Work
The refresh interceptor everyone writes, and the five bugs it has — thundering herds, cross-tab races, retrying non-idempotent requests, background tabs, and the logout that discards a half-written form.
Every single-page application with short-lived access tokens ends up with a function that intercepts a 401, refreshes the token, and retries the request. It is thirty lines, it works immediately, and it contains at least five bugs that will not appear until you have real users on real networks with multiple tabs open.
This article is about those bugs, and about the harder question underneath them: what should happen to the thing the user was doing when their session ended.
The naive version
async function apiCall(url, options) {
let res = await fetch(url, options);
if (res.status === 401) {
await refreshToken();
res = await fetch(url, options); // retry
}
return res;
}
Ships on Tuesday, works in development, and fails in five distinct ways.
Bug 1: the thundering herd
A page loads and fires eight parallel requests. The access token expired thirty seconds ago. All eight get a 401. All eight call refreshToken().
Now eight refresh requests hit your server simultaneously with the same refresh token. If you implement refresh token rotation — and you should — the first one rotates the token and the other seven present a token that has just been superseded. Your server correctly identifies this as reuse, concludes the token was stolen, revokes the entire family, and logs the user out.
Your security feature just fired on your own client. This is the most common cause of "it randomly logs me out," and it is entirely self-inflicted.
The fix is single-flight: at most one refresh in progress, with everyone else awaiting the same promise.
let refreshPromise = null;
function refreshOnce() {
if (!refreshPromise) {
refreshPromise = doRefresh().finally(() => { refreshPromise = null; });
}
return refreshPromise;
}
Note the .finally placement — clearing the promise on both success and failure. Clear it only on success and a single failed refresh permanently poisons every subsequent attempt.
Bug 2: the cross-tab race
Single-flight fixes one page. The user has your app open in four tabs. Each tab has its own JavaScript context, its own module state, and its own refreshPromise. Four tabs, four refreshes, same reuse detection, same logout.
Two mechanisms solve this, and the better one is not the obvious one.
navigator.locks gives you a real cross-context mutex, and it is the correct primitive:
async function refreshWithLock() {
return navigator.locks.request('auth-refresh', async () => {
// Re-check inside the lock — another tab may have refreshed while we waited.
if (!isExpired(getToken())) return getToken();
return doRefresh();
});
}
The re-check inside the lock is essential. Without it, each tab waits its turn and then performs a redundant refresh anyway — you have serialized the herd rather than eliminated it.
BroadcastChannel handles the other half: telling the other tabs about the result, so they update their in-memory token and do not discover it themselves.
const channel = new BroadcastChannel('auth');
channel.onmessage = (e) => {
if (e.data.type === 'token-refreshed') setToken(e.data.token);
if (e.data.type === 'signed-out') redirectToLogin();
};
If you keep tokens in an HttpOnly cookie — which you should — a lot of this simplifies, because the browser handles the token and the tabs share it automatically. You still need the lock to prevent concurrent refresh calls, but you no longer need to broadcast the token value itself.
Bug 3: retrying requests that must not be retried
The naive interceptor retries the original request after refreshing. For a GET, harmless. For POST /api/payments, potentially a double charge.
The danger is subtle: the original request may have succeeded on the server and returned 401 for an unrelated reason, or succeeded and had its response lost. A blind retry then executes it twice.
Two rules:
Refresh proactively, so retries are rare. Check expiry before sending, not after failing. If the token expires within the next thirty seconds, refresh first. This turns the 401 path into an exception rather than the normal flow.
async function authedFetch(url, options) {
let token = getToken();
if (isExpiringSoon(token, 30_000)) token = await refreshWithLock();
return fetch(url, { ...options, headers: { ...options.headers, Authorization: `Bearer ${token}` } });
}
Make retryable requests idempotent. For anything non-idempotent, send a client-generated idempotency key and have the server return the original result for a repeated key. This is the same mechanism as webhook deduplication, and it is what makes retry safe in general rather than case by case.
If you cannot do either, do not auto-retry non-idempotent requests. Surface the failure and let the user decide.
Bug 4: distinguishing "expired" from "revoked"
A 401 has two very different meanings, and the interceptor treats them identically.
The token expired. Normal. Refresh and continue. The user should never notice.
The session was revoked — signed out elsewhere, password changed, reuse detected, account suspended. Refreshing will fail, and it should. Retrying is pointless.
An interceptor that does not distinguish these will loop: 401, refresh, refresh fails with 401, intercept that 401, refresh again. A refresh loop is a self-inflicted denial of service against your own auth endpoint, and it is remarkably easy to write.
Two guards. Never intercept a 401 from the refresh endpoint itself — that path must be excluded explicitly. And count attempts: one refresh per request, and a failed refresh means sign out, not another attempt.
It helps enormously if your API distinguishes the cases in the response body — { "error": "token_expired" } versus { "error": "session_revoked" } — so the client can act correctly instead of guessing.
Bug 5: the background tab
A tab left open overnight is a specific set of problems.
Browsers throttle timers in background tabs, so a setInterval that refreshes every ten minutes may fire every few minutes at best, or not at all. A laptop that sleeps stops all timers entirely; on wake, the token expired six hours ago.
Do not drive refresh from a timer. Refresh on demand, when a request is about to be made, and additionally on visibilitychange when the tab becomes visible again:
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && isExpiringSoon(getToken())) {
refreshWithLock().catch(handleSessionEnded);
}
});
This means the tab the user just switched to is ready before they click anything, without any tab burning cycles in the background.
The part that actually matters: the user's work
All of the above is plumbing. This is the part users care about, and it is the part most implementations handle worst.
The scenario: someone has spent twenty minutes writing something. Their session expires. They click save. The interceptor tries to refresh, the refresh fails because the session was revoked, and the app redirects to the login page. Twenty minutes of work, gone.
There is no excuse for this, and it is entirely avoidable.
Never redirect away on session loss when there is unsaved state. Show a modal over the current page. The application stays exactly where it is, in memory, with everything intact.
Re-authenticate in place. A modal with an email and password field — or better, a passkey prompt — that establishes a new session without unmounting anything. On success, dismiss the modal and retry the failed request. The user loses a few seconds instead of twenty minutes.
If you must navigate, persist first. Serialize in-progress form state to sessionStorage before redirecting, and restore it after login. Encode where they were so they return to the same place, not to a dashboard.
Warn before it happens. If you know when the session ends, tell the user at the two-minute mark with a "stay signed in" button. A predicted interruption is a minor annoyance; a surprise one is a lost customer.
Handle the signed-out-elsewhere case gracefully. When a BroadcastChannel message says another tab signed out, this tab should not silently discard state either. Show the same modal.
The cases the interceptor cannot see
Not every request goes through your fetch wrapper, and the ones that do not are where expiry produces its strangest bugs.
WebSockets. A socket authenticated at connection time stays open long after the token that opened it expired. There is no 401 to intercept, because there is no request. You need an explicit strategy: either the server closes the socket when the token expires and the client reconnects with a fresh one, or the client periodically sends a refreshed token over the existing connection. Doing neither means a connection whose authorization was checked once, hours ago — which is the same problem as a very long-lived access token, and it is easy to miss because nothing fails visibly.
Server-sent events and long polling. Same shape. EventSource in particular cannot set custom headers, so people fall back to a token in the query string — which puts a credential in every access log along the path. If you must, use a short-lived single-purpose token for the stream rather than your normal access token.
File uploads. A large upload can outlive the token that started it, and it is the worst possible request to fail and retry. Refresh immediately before starting a long upload, or use a pre-signed URL so the upload itself carries its own authorization and does not depend on the session at all.
Prefetched navigations. Frameworks prefetch data for links the user has not clicked. A prefetch that triggers a refresh is fine; a prefetch that triggers a failed refresh and then a sign-out is a user being logged out by hovering over a link. Mark prefetch requests so the interceptor treats a 401 on them as "skip silently," never as a session-ended event.
Third-party embeds. Anything in an iframe has its own context and cannot see your token or your lock. If an embedded component needs authorization, it needs its own short-lived token passed in — which is the postMessage handoff pattern rather than a shared session.
Testing the paths that only fail in production
Every bug in this article is invisible in normal development, because in development the token is fresh, there is one tab, and the network works. Make them reproducible:
Set a very short access token lifetime in your dev environment — thirty seconds. Every developer will encounter the refresh path constantly, and the bugs surface immediately instead of at 3 a.m.
Add a "expire my token now" button in a dev-only debug panel. Being able to trigger the exact condition on demand turns a heisenbug into a test case.
Test with two tabs, deliberately. The cross-tab race requires two contexts, so it will never appear in a single-tab manual test or in a standard component test.
Simulate a revoked session, not just an expired one, and assert the client does not loop.
Throttle the network and check that a refresh that takes four seconds does not produce eight parallel refreshes behind it.
An integration test that opens two browser contexts sharing storage, expires the token, fires concurrent requests from both, and asserts exactly one refresh reached the server is worth more than any amount of careful reading of the code.
Where to keep the token
Briefly, because it determines how much of the above you have to build.
HttpOnly cookie — the right default. Unreadable by script, so XSS cannot exfiltrate it, and the browser handles cross-tab sharing for you. Requires CSRF protection (SameSite=Lax plus a token on state-changing requests) and does not suit APIs on a different origin without care.
In memory — safe from persistent XSS exfiltration and lost on every page refresh, which means a silent-refresh dance on load. Combined with an HttpOnly refresh cookie this is a genuinely good design: the refresh token is unreachable by script, and the access token lives only as long as the page.
localStorage — readable by any script on the page. One XSS bug exfiltrates every token. Convenient, and it is the configuration behind a large share of real SPA token thefts. Avoid.
The reasoning is expanded in session management best practices.
Putting it together
let inFlight = null;
async function getValidToken() {
const token = getToken();
if (token && !isExpiringSoon(token, 30_000)) return token;
if (!inFlight) {
inFlight = navigator.locks
.request('auth-refresh', async () => {
const current = getToken();
if (current && !isExpiringSoon(current, 30_000)) return current; // another tab won
const fresh = await doRefresh(); // throws on revoked
broadcast({ type: 'token-refreshed', token: fresh });
return fresh;
})
.finally(() => { inFlight = null; });
}
return inFlight;
}
export async function authedFetch(url, options = {}) {
let token;
try {
token = await getValidToken();
} catch {
onSessionEnded(); // modal — NOT a redirect
throw new SessionEndedError();
}
return fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${token}` },
});
}
Proactive, single-flight, cross-tab coordinated, no blind retries, and it never throws away the user's work.
Key takeaways
- Refresh proactively before expiry, not reactively on 401. It eliminates the retry question almost entirely.
- Single-flight within the page and lock across tabs — and re-check inside the lock, or you have only serialized the herd.
- Concurrent refreshes trip reuse detection and log users out. This is the most common cause of "it randomly signs me out."
- Never blindly retry non-idempotent requests. Use idempotency keys, or do not retry.
- Distinguish expired from revoked, and never intercept a 401 from the refresh endpoint — that is how refresh loops happen.
- Drive refresh from requests and
visibilitychange, never from a background timer. - On session loss, show a modal and re-authenticate in place. Redirecting away from unsaved work is the bug users actually remember.
Written by
Emilian GheoneaSenior Blockchain & Full-Stack Software Engineer. I build EmbedAuth — an embeddable authentication platform for SaaS — and write about the auth problems most teams hit too late.
Related articles
React Auth
Auth in React Server Components: Where the Session Actually Lives
The App Router changed where authentication runs. Layouts that don't re-render, middleware that shouldn't authorize, the client/server boundary and what leaks across it, streaming and Suspense, and why every route must check for itself.
Jul 15, 2026 9 minReadReact Auth
Why Third-Party Cookies Break Embedded Auth (and the Fix)
How browser privacy changes — Safari ITP, Chrome's Privacy Sandbox, storage partitioning — broke cookie-based iframe authentication, and the token-by-message pattern that replaces it.
Jul 1, 2026 6 minReadReact Auth
Why Iframe Authentication Is Difficult
Iframe-based auth looks like a clean way to embed a sign-in form. The browser thinks otherwise. Here's what actually breaks — cookies, postMessage, ITP, focus, autofill — and the patterns that survive contact with real users.
May 4, 2026 8 minReadJWT
JWTs vs Opaque Tokens: Stop Choosing Based on Vibes
The real trade-off is where authorization state lives and how fast it can change. A decision framework covering revocation latency, introspection, hybrid designs, and the failure modes each choice hands you.
Jul 27, 2026 8 minRead