This article is published in English.
Why Concurrent 401s Log Users Out, and the Single-Flight Refresh Fix
How parallel requests and refresh-token rotation race each other into surprise logouts, and how one shared refresh promise in an Axios interceptor prevents it.
A user is halfway through a long form when the app drops them back on the login screen. There is no error and no crash, and everything they typed is gone. The token expiry logic looks correct, the refresh flow looks correct, and yet sessions keep ending early. Below you will see why that happens when several requests hit an expired token at the same moment, why the bug is so hard to spot by reading the code, and how a small change to an Axios interceptor, sharing a single in-flight refresh promise, makes it disappear.
The symptom: logouts that should be impossible
Picture a case management platform used by field staff. Officers enter inspection or exam data on tablets, often over unreliable mobile connections. A report comes in, then another from a different user: the app signed them out while they were filling in a form. On a system like this, that is not a small annoyance. It can mean redoing twenty minutes of careful data entry.
The obvious suspect is token expiry, and on inspection it seems innocent. Access tokens last 15 minutes. When an API call returns 401, the client calls the refresh endpoint, stores the new token and continues. Nothing in that description should end a session in the middle of use. When the obvious explanation checks out, the productive move is to stop trusting your reading of the code and reproduce the failure.
Reproducing a race around token expiry
The bug only appears under one condition: several API calls fired close together, right around the moment the access token expires. A multi-section form is a perfect trigger. It may send a validation request, an autosave request and a file-upload status check within a few milliseconds of each other. If the token expires inside that window, all of them come back 401 almost simultaneously.
The interceptor was written for the single-request case: on a 401, call the refresh endpoint, receive a new token, and replay the original request. Tested with one call at a time, it works perfectly. With four calls failing together, however, it starts four independent refresh requests at once.
That collides with a sensible backend decision: refresh token rotation. When a refresh token is used, the server invalidates it and issues a new one, so a stolen refresh token cannot be replayed indefinitely. Now follow the four parallel refreshes:
- The first refresh request succeeds and receives a new refresh token.
- The second, third and fourth were already sent carrying the old refresh token, before the first response arrived.
- The server rejects them, because that token has just been invalidated.
- The interceptor interprets a failed refresh as "the session is genuinely over" and logs the user out.
The access token's lifetime was never the problem. The broken assumption was that refreshes are never concurrent. Many rotation implementations go further and treat reuse of an old refresh token as a sign of theft, revoking the entire token family, which turns the same race into an even more aggressive logout.
Why reading the code did not reveal it
This is arguably a more valuable lesson than the fix. Read top to bottom, the interceptor is correct: catch the 401, refresh, retry. That is the sequence it was written to perform, and rereading it only confirms that sequence.
What reading hides is that the interceptor does not run once. It runs once per failed request, and those requests are concurrent, not sequential. Debugging it as though it were a linear script means reasoning about what each step does while ignoring when each step happens.
The pattern becomes visible almost immediately once you log a timestamp on every refresh call. In a scenario like this one, you would see four refresh attempts within roughly 40 milliseconds of each other, all aimed at the same endpoint. An hour of staring at logic can be replaced by two minutes of looking at timing. When a bug "cannot happen" according to the code, instrumenting the order and timing of events is often faster than reading the code again.
The fix: one refresh in flight, everyone else waits
Once the real shape of the problem is clear, the fix is small. Instead of letting every 401 start its own refresh, the interceptor checks whether a refresh is already underway. If one is, the new failure does not start another; it waits on the same pending promise and retries when that promise resolves. This is often called a single-flight pattern.
The first piece is a module-level variable that holds the in-progress refresh, or null when no refresh is running:
let refreshPromise = null;
The handler below is called for a request that failed with 401. If refreshPromise is empty, it calls refreshAccessToken() and stores the resulting promise, attaching a finally that resets the variable to null whether the refresh succeeds or fails, so the next expiry can trigger a fresh attempt. Every caller, the first and all later ones, then awaits that same promise, sets the new bearer token on its original request config, and replays the request through axios.
async function handleUnauthorized(originalRequest) {
if (!refreshPromise) {
refreshPromise = refreshAccessToken().finally(() => {
refreshPromise = null;
});
} const newToken = await refreshPromise;
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return axios(originalRequest);
}
The syntax matters less than the idea: a single shared promise that every failed request waits on, instead of each one racing to refresh. The first 401 starts the refresh; any 401 arriving while it is in flight piggybacks on that result rather than spending the refresh token again. Because JavaScript runs this code on a single thread, the check and assignment of refreshPromise cannot interleave between callers, which is what makes such a simple guard sufficient.
A few details are worth handling when you wire this into a real interceptor:
- If the refresh itself fails, every waiting request rejects together, so the app can perform one clean logout instead of several competing ones.
- Mark replayed requests (for example with a
_retryflag on the config) so a request that fails with401again after refreshing does not loop forever. - Make sure the refresh request itself bypasses this handler, or a
401from the refresh endpoint will try to refresh itself. - Separate browser tabs each have their own JavaScript context, so they can still race each other; if that matters for your app, coordinate across tabs or refresh proactively before expiry.
For the server side of the same design, including rotation and revocation, see our guide on refresh token strategy for Node.js authentication.
Key takeaways
With the single-flight refresh in place, the surprise logouts stop. The broader change is a habit: treat concurrent calls as the default case, not an edge case to handle later. Whenever you write an interceptor, a queue or any handler that reacts to an asynchronous failure, ask what happens if it runs four times within a fifty-millisecond window. Production traffic from busy forms and flaky mobile connections will produce that situation sooner or later.
- The bug was not really about refresh tokens; it was about code that assumed events happen one at a time.
- Refresh token rotation is a good security practice, and it is exactly what exposes duplicate refresh requests.
- Code that looks correct when read line by line can still fail under concurrency; log timestamps to see when things happen, not only what happens.
- Share one in-flight refresh promise across all failing requests, reset it in
finally, and guard against retry loops.