The Refresh Queue
How one expired token refreshes cleanly even when many requests fail at once — the isRefreshing flag and pending queue.
Access tokens are short-lived on purpose. So sooner or later, mid-session, the token in localStorage goes stale — and the very next request to the API comes back 401 Unauthorized. The Education Hub handles this so smoothly that you never notice it happened: the request quietly refreshes the token and retries itself.
The interesting part isn't the refresh. It's what happens when several requests fail at the same time. That's the race this page is about, and the whole mechanism lives in one place — the Axios response interceptor in src/services/api/api.instance.ts.
One file does all of this
Everything below is the response interceptor registered on the shared axiosInstance. There's no separate auth library doing the work — it's about sixty lines of plain code. Once you've read it, you've read the whole refresh story.
The race
Picture a dashboard that loads. It fires five requests almost at once — profile, notifications, the schedule, two widgets. The access token just expired, so all five come back 401 together.
Naive code would react to each 401 independently and fire five parallel refresh calls. The server burns four of them, the refresh token rotates underneath you, and you end up logged out — or worse, in a flickering loop. The fix is to make sure that only the first 401 actually refreshes, and everyone else waits for it.
Two module-level variables make that possible. They live outside the interceptor function so their state is shared across every request:
let isRefreshing = false
let pendingQueue: Array<{
resolve: (token: string) => void
reject: (error: unknown) => void
}> = []isRefreshing is the lock — is a refresh already in flight? pendingQueue is the waiting room — the requests that 401'd while the lock was held, parked until the new token arrives.
The flag and the queue
When a response fails, the interceptor first decides whether this 401 is even refreshable. Three early exits guard against loops and dead ends:
if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error)
}
// Don't try to refresh if the failing request was the refresh call itself
if (originalRequest.url?.includes('/auth/refresh')) {
clearStoredAuth()
return Promise.reject(error)
}Read those guards carefully — they're the safety rails:
- Not a 401, or already retried? Reject. The
_retryflag (set later) means we've already refreshed once for this request — refreshing again would be an infinite loop. - The failing request was the refresh call? Then our refresh token itself is dead. There's nothing left to try, so we
clearStoredAuth()and reject — a hard logout.
If none of those apply, we check the lock. The first 401 finds isRefreshing still false and becomes the leader: it claims the lock, marks the request _retry, and reads the refresh token.
originalRequest._retry = true
isRefreshing = true
const { refreshToken } = getStoredAuth()
if (!refreshToken) {
isRefreshing = false
clearStoredAuth()
processQueue(error, null)
return Promise.reject(error)
}No refresh token in storage means there's nothing to refresh with — clear, drain the queue with the error, reject. (We'll get to processQueue in a moment.)
Draining the queue
Now the two paths diverge. The leader POSTs to the refresh endpoint and, on success, writes the fresh tokens and wakes everyone up:
const { data } = await axios.post(
`${import.meta.env.VITE_API_URL}/api/v1/auth/refresh`,
{ refreshToken },
)
setStoredAuth(data.accessToken, data.refreshToken)
processQueue(null, data.accessToken)
// retry the original request with the new token...
return axiosInstance(originalRequest)Meanwhile, every concurrent 401 that arrived while isRefreshing was true takes a different branch. It doesn't call refresh. Instead it returns a brand-new Promise and parks its resolve/reject on the queue:
if (isRefreshing) {
return new Promise<string>((resolve, reject) => {
pendingQueue.push({ resolve, reject })
}).then((token) => {
originalRequest.headers = {
...originalRequest.headers,
Authorization: `Bearer ${token}`,
}
return axiosInstance(originalRequest)
})
}That request is now suspended — its Promise won't settle until someone calls resolve or reject on it. The thing that does that is processQueue, which the leader calls once the refresh resolves:
function processQueue(error: unknown, token: string | null) {
for (const { resolve, reject } of pendingQueue) {
if (token) resolve(token)
else reject(error)
}
pendingQueue = []
}One call, and the whole waiting room wakes up at once. Each parked request resolves with the new token, swaps it into its Authorization header, and retries through axiosInstance(originalRequest). Five failed requests, one refresh, five clean retries — and the user saw nothing.
The leader retries too
The request that triggered the refresh isn't in the queue — it's the leader. After processQueue, it re-runs itself directly with axiosInstance(originalRequest). So everybody retries: the one that fired the refresh, and everyone who waited on it.
When refresh fails
What if the refresh itself fails — the refresh token expired, the server says no? The leader's catch block runs the unhappy path, and it's the mirror image of success:
} catch (refreshError) {
clearStoredAuth()
processQueue(refreshError, null)
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}processQueue(refreshError, null) passes a null token, so the loop takes the reject branch for every parked request — the whole waiting room fails together instead of hanging forever. Storage is cleared, and the session is effectively over.
Why the finally block matters
isRefreshing = false lives in finally, so the lock is released whether the refresh succeeded or threw. Forget that, and a single failed refresh would leave the lock stuck true forever — every future 401 would queue up behind a refresh that's never coming, and the app would silently freeze its own network layer.
The pieces fit together as one rule: the first 401 refreshes, everyone else waits, and the queue is always drained — with the new token on success, with the error on failure. No parallel refreshes, no infinite loops, no orphaned requests.
Where to go next
Sessions & Tokens
Where access and refresh tokens are stored, and how getStoredAuth / setStoredAuth read and write them.
The Result Pattern
How the rest of the data layer turns failures into values instead of thrown errors.
Auth & Permissions (API)
The server side of the handshake — what /auth/refresh actually checks and rotates.