Skip to content

Collapsing concurrent 401s into a single refresh

5 min read

  • auth
  • react
  • typescript
When a token expires mid-page, every in-flight request fails at once. Queue them behind one refresh instead of firing one per failure.

A page that fires six requests just after a token expires gets six 401s, and a naive interceptor turns those into six refresh calls. Five of them are useless. On any identity provider that rotates refresh tokens, the second one gets the whole session revoked. The fix is two variables — an in-flight promise and a queue — and it is the difference between a page that stutters and a user who gets signed out for no reason they can describe.

Run it before reading the explanation. Switch between the two modes and watch the refresh count:

Interactive. Send six requests, then switch to naive mode and send them again. Full version, with the walkthrough.

The interceptor everybody writes first

Here it is, and it is not stupid. It is the correct three lines for the case you had in mind:

interceptor.ts
client.interceptors.response.use(undefined, async (error) => {
  if (error.response?.status !== 401) throw error;
  await refresh();
  return client(error.config);
});

One request 401s, one refresh goes out, the request replays. Ship it.

The case you did not have in mind is a dashboard mounting. The user comes back to the tab after lunch, the page fires requests for the header, the sidebar counts, the table, the filters and two lazily-mounted widgets, and every one of them is holding a token that expired eleven minutes ago. Six 401s arrive within about 300 ms of each other. Six interceptors run. Six refreshes go out.

Why this is worse than six wasted requests

If your refresh endpoint is idempotent and your tokens do not rotate, this is merely wasteful: six calls where one would do, six times the load on the identity provider, and a race where whichever response lands last wins.

But the OAuth security best-practice guidance recommends refresh-token rotation, and most identity providers implement it — Auth0, Okta and Keycloak all do. Under rotation, each refresh returns a new refresh token and retires the one that was used. A second request arriving with the retired token is, from the server's point of view, exactly what a stolen token being replayed looks like. It cannot tell your race condition from an attacker, so it does the correct thing: it revokes the entire token family.

Your user is now signed out. Mid-session, with no error they can act on, on a page that was working a second ago. It reproduces only when several requests happen to be in flight at the moment of expiry, which is to say it reproduces on their machine and never on yours.

That is the failure the demo above performs in naive mode. It is not a hypothetical, and it is why "we'll add the flag if it becomes a problem" is the wrong instinct: by the time it is a problem, the evidence is a support ticket saying "it logged me out again".

The collapse, in two variables

refresh-once.ts
let inFlight: Promise<string> | null = null;
 
export function refreshOnce(): Promise<string> {
  // The first caller starts the refresh and owns the promise. Everyone who
  // arrives while it is unresolved gets the same promise back — they do not
  // start their own, and they do not need to know they were not first.
  inFlight ??= refresh().finally(() => {
    inFlight = null;
  });
 
  return inFlight;
}

Two things are load-bearing here and both are easy to get wrong.

finally, not then. If the refresh rejects and you clear the flag in then, inFlight stays pointing at a rejected promise forever and every subsequent request awaits a permanent failure. finally clears it either way, so the next 401 after a failed refresh gets a fresh attempt.

Clear it after resolution, not before replay. The window between "refresh resolved" and "requests replayed" must not be a window in which a new 401 can start a second refresh. Because inFlight is only nulled inside finally, and finally runs before the awaiting callers resume, the ordering is guaranteed by the promise semantics rather than by hoping.

Wired into the interceptor:

interceptor.ts
client.interceptors.response.use(undefined, async (error: AxiosError) => {
  const request = error.config;
  if (error.response?.status !== 401 || !request) throw error;
 
  // Guard against the refresh call itself 401ing, which would recurse
  // until the stack gives out.
  if (request.url === REFRESH_URL || request._retried) throw error;
  request._retried = true;
 
  const token = await refreshOnce();
  request.headers.Authorization = `Bearer ${token}`;
  return client(request);
});

The _retried flag is not optional. Without it, a request that 401s again after a successful refresh — because the new token is also rejected, because the user genuinely lost access to that resource — retries forever. I have watched that one saturate a browser's connection pool in about four seconds.

The part the diagram hides

Everything above describes the happy failure. There is a second question that matters just as much: what happens when the refresh itself fails?

refresh-once.ts
export function refreshOnce(): Promise<string> {
  inFlight ??= refresh()
    .catch((error) => {
      // One rejection, propagated to every queued caller, and one sign-out —
      // not one sign-out per queued request, which is how you end up
      // redirecting to /login six times and losing the return URL.
      onSessionExpired();
      throw error;
    })
    .finally(() => {
      inFlight = null;
    });
 
  return inFlight;
}

Because all six callers awaited the same promise, all six see the same rejection, and onSessionExpired runs once because it is inside the shared promise rather than in each caller's catch. That is a real benefit of collapsing that nobody mentions: the recovery path collapses too.

The trade-off

Queuing behind one refresh means every waiting request pays for the slowest part of the chain. In the demo, six requests that would each have taken about 600 ms take roughly 1.4 seconds, because they all sit through one 700 ms refresh before replaying. Under the naive interceptor — in the world where rotation does not exist and it would have worked — the same page finishes sooner, because each request refreshes in parallel with the others.

So the trade is real: a little latency on one unlucky page load, against a class of failure that is expensive to diagnose and impossible to reproduce on demand. I take that trade every time, and I would push back hard on anyone who framed the flag as an optimisation to be justified by profiling. It is not an optimisation. It is what makes concurrent refreshes impossible rather than unlikely, and that is the distinction between a bug you fixed and a bug you have not seen yet.

Where I would put it

Not in the interceptor. refreshOnce belongs in its own module with no dependency on the HTTP client, for the same reason the version behind the demo above is a pure function with injectable sleep and now: it is the only part of this with interesting logic, and logic you cannot test without a network is logic that gets verified by staring at it.

refresh-once.test.ts
it("makes one refresh call for six concurrent 401s", async () => {
  const refresh = vi.fn().mockResolvedValue("new-token");
  const results = await Promise.all(
    Array.from({ length: 6 }, () => refreshOnce(refresh)),
  );
 
  expect(refresh).toHaveBeenCalledTimes(1);
  expect(results).toEqual(Array(6).fill("new-token"));
});

Six lines, no server, and it fails the moment somebody simplifies the flag away. That test is the actual deliverable — the pattern is easy, remembering why it is there in eight months is not.

If you want to see the queue forming request by request, the full demo shows the timeline with the in-flight flag and the queue printed live beside it. The case study covers where this sits in a larger identity layer, and the previous post argues the token should not have been in the browser to begin with.