Refreshing Tokens Without a Stampede: Support24's Axios Interceptor
How Support24's frontend refreshes an expired access token exactly once, even when five requests hit a 401 at the same moment — using a single Axios interceptor and a small request queue.
Support24's frontend is a fairly typical setup: React, TanStack Query for server state, Axios for HTTP. The interesting part isn't any of those — it's what happens the moment an access token expires while the UI has several requests in flight at once.
The failure mode this is guarding against
Picture a dashboard screen that fires off four or five API calls on mount — shifts, timesheets, profile, notifications. If the access token expired a second ago, all of them come back 401 within milliseconds of each other. The naive fix — "on 401, refresh the token and retry" — works fine for one request and breaks under exactly this load: five requests each independently call the refresh endpoint, the backend issues five new token pairs, and only the last one written to storage survives. The other four requests retry with tokens that are already stale.
Attaching the token is the easy half
Every outgoing request goes through one interceptor that checks the stored access token and skips attaching it if it's already expired (decoded straight from the JWT payload, no network call needed):
apiClient.interceptors.request.use((config) => {
const accessToken = tokenStorage.getAccessToken();
if (accessToken && !tokenStorage.isTokenExpired(accessToken)) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
That's the straightforward direction. The response side is where the concurrency problem actually gets solved.
One refresh in flight, everyone else waits in line
Two module-level variables do the coordinating: a boolean flag and an array of pending promise callbacks.
let isRefreshing = false;
let failedQueue: Array<{
resolve: (value?: unknown) => void;
reject: (reason?: unknown) => void;
}> = [];
When a response interceptor sees a 401, it checks that flag first. If a refresh is already underway, the request doesn't trigger a second one — it just pushes itself onto failedQueue and returns a promise that stays pending:
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return apiClient(originalRequest);
});
}
Only the request that finds isRefreshing still false actually calls the refresh endpoint, and it flips the flag before awaiting anything else:
originalRequest._retry = true;
isRefreshing = true;
try {
const newAccessToken = await refreshAccessToken();
processQueue(null, newAccessToken);
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return apiClient(originalRequest);
} finally {
isRefreshing = false;
}
Once the new token comes back, processQueue walks the queued callbacks and resolves every one of them with that same token — so four requests that all failed with a stale token all retry with the one new token, instead of racing each other to mint their own.
const processQueue = (error: unknown, token: string | null = null) => {
failedQueue.forEach(({ resolve, reject }) => {
error ? reject(error) : resolve(token);
});
failedQueue = [];
};
The originalRequest._retry flag is the other half of correctness here — it stops a request that fails again after a legitimate refresh from looping back into the refresh branch a second time and queuing forever.
When the refresh itself fails
If the refresh call throws — refresh token expired, revoked, network error — every queued request gets rejected with that same error, tokens are cleared from storage, and the user is bounced to /login with a toast. There's no partial-failure state where some requests quietly keep using dead credentials; the whole batch fails together, which is the right default for anything gated behind auth.
Takeaway
The pattern here is small — a boolean and an array — but it's doing real work: turning "N requests independently discover the token is stale" into "one request discovers it, and N requests wait on that one discovery." It's a good example of a concurrency bug that never shows up in manual testing (you rarely click five things at once) but appears immediately under real usage, where a dashboard genuinely does fire several requests together. Worth building this queue in from the start rather than after the first bug report about someone getting logged out for no visible reason.