Naalya Handbook
Authentication

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:

src/services/api/api.instance.ts
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:

src/services/api/api.instance.ts
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 _retry flag (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.

src/services/api/api.instance.ts
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:

src/services/api/api.instance.ts
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:

src/services/api/api.instance.ts
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:

src/services/api/api.instance.ts
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:

src/services/api/api.instance.ts
} 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

On this page