Handle Token Refresh Safely in Concurrent API Calls

Advanced18 min interview
Skills tested:
Identifying the thundering herd problem in token refresh logicUsing a module-level Promise variable to share a single in-flight refreshResetting the shared Promise variable in a finally block so future refreshes can trigger againQueuing failed requests to retry with the new token after refresh completesHandling refresh failure: clearing tokens and redirecting to login

Advertisement

🧩 Scenario

In a real codebase, dashboard pages often load 5 to 10 API calls simultaneously. If an access token expires mid-session and all 10 requests get a 401, each request independently calls the refresh endpoint. This floods the auth server with simultaneous refresh requests, which can cause race conditions where one refresh invalidates the token that another refresh just issued. The shared promise pattern ensures exactly one refresh runs and all other callers wait for it.

Architecture Walkthrough

The Problem: N Parallel 401s Trigger N Refreshes

Access tokens have short lifetimes (typically 15 minutes). When a token expires, the next batch of API calls all receive a 401 Unauthorized response simultaneously. Without coordination, each failed request independently calls the token refresh endpoint. With 10 concurrent requests, 10 refresh calls fire at the same moment.

This creates two problems. First, the auth server is hit with unnecessary load. Second, many auth servers invalidate the existing refresh token when it is used. If Request A refreshes the token first and Request B starts its refresh a millisecond later using the now-invalidated refresh token, Request B's refresh fails and the user is logged out even though Request A successfully obtained a new token.

The Shared Promise Pattern

The solution is to store the in-flight refresh operation as a module-level Promise variable. Before starting a new refresh, check whether this variable is already set. If it is, the refresh is already in progress and the caller should wait for the existing Promise to resolve. If it is not set, start a new refresh and assign the Promise to the variable so future callers can share it.

The variable must be reset to null in a finally block so that the next token expiry (which will happen again in 15 minutes) can trigger a fresh refresh. Using then for the reset is wrong: if the refresh fails, then does not run and the variable stays set, permanently blocking future refresh attempts.

Queuing and Retrying Failed Requests

The pattern can be extended to replay the original failed requests after the refresh completes. A queue of resolve/reject callbacks is populated by each intercepted 401. When the refresh Promise resolves, all queued callbacks are called with the new token. When it rejects, all are rejected with the refresh error. Each queued callback retries its original request with the new Authorization header.


Key Code Explained

// Module-level shared promise — lives outside any component or function
let refreshPromise = null;

async function refreshTokenOnce() {
  // If a refresh is already in progress, wait for it instead of starting another
  if (refreshPromise) return refreshPromise;

  refreshPromise = fetch('/auth/refresh', { method: 'POST', credentials: 'include' })
    .then(async (res) => {
      if (!res.ok) throw new Error('Refresh failed');
      const { accessToken } = await res.json();
      setAccessToken(accessToken); // store the new token
      return accessToken;
    })
    .finally(() => {
      refreshPromise = null; // CRITICAL: reset so next expiry can trigger a new refresh
    });

  return refreshPromise;
}

// HTTP interceptor that handles 401 responses
async function apiRequest(url, options = {}) {
  const token = getAccessToken();
  const res = await fetch(url, {
    ...options,
    headers: { ...options.headers, Authorization: `Bearer ${token}` },
  });

  if (res.status === 401) {
    try {
      const newToken = await refreshTokenOnce(); // all callers share this single Promise
      // Retry the original request with the new token
      return fetch(url, {
        ...options,
        headers: { ...options.headers, Authorization: `Bearer ${newToken}` },
      });
    } catch (err) {
      // Refresh itself failed — token is expired or revoked
      clearTokens();
      window.location.href = '/login';
      throw err;
    }
  }

  return res;
}

// Extended version: queue callers during the refresh and replay all at once
const failedQueue = [];

function processQueue(error, token) {
  failedQueue.forEach(({ resolve, reject }) => {
    if (error) reject(error);
    else resolve(token);
  });
  failedQueue.length = 0;
}

async function apiRequestWithQueue(url, options = {}) {
  const token = getAccessToken();
  const res = await fetch(url, {
    ...options,
    headers: { ...options.headers, Authorization: `Bearer ${token}` },
  });

  if (res.status === 401) {
    if (refreshPromise) {
      // Another caller is already refreshing — queue this request
      return new Promise((resolve, reject) => {
        failedQueue.push({ resolve, reject });
      }).then((newToken) =>
        fetch(url, {
          ...options,
          headers: { ...options.headers, Authorization: `Bearer ${newToken}` },
        }),
      );
    }

    try {
      const newToken = await refreshTokenOnce();
      processQueue(null, newToken); // replay all queued requests
      return fetch(url, {
        ...options,
        headers: { ...options.headers, Authorization: `Bearer ${newToken}` },
      });
    } catch (err) {
      processQueue(err, null); // fail all queued requests
      clearTokens();
      window.location.href = '/login';
      throw err;
    }
  }

  return res;
}

The finally reset is the critical detail. If the refresh request fails (network error, expired refresh token), then does not run. Without finally, refreshPromise stays pointing to a rejected Promise. Every subsequent request that tries to await refreshTokenOnce() will immediately get the same rejection, and no future refresh can ever be attempted. The finally ensures the variable is reset regardless of success or failure.


Tradeoffs

ApproachRequests to auth serverComplexityRetries original calls
No coordination (naive)N (one per 401)LowNo
Shared promise only1LowNo
Shared promise + queue1ModerateYes
Axios interceptor with queue1Low (library handles)Yes

What Interviewers Actually Check

  • Whether you understand why multiple 401 responses cause multiple refresh calls without coordination
  • Whether you know the shared promise pattern and why the variable lives at module scope
  • Whether you know to reset the variable in finally, not then
  • Whether you can describe how to retry the original failed requests after a successful refresh
  • Whether you know how to handle a refresh failure without causing an infinite retry loop

Follow-Up Questions

  1. How would you add a maximum retry count to the pattern to prevent infinite loops if the new token also expires immediately?
  2. How does axios-auth-refresh or similar libraries implement this pattern under the hood?
  3. If the shared refreshPromise is in module scope but the app has multiple browser tabs open, does each tab share the same promise?
  4. How would you test this pattern to verify that exactly one refresh request is made regardless of how many 401 responses arrive simultaneously?
  5. How would you handle the case where the user logs out in one tab while a refresh is in progress in another?

Common Candidate Mistakes

  • Triggering a separate refresh call for each 401 response, not realizing that multiple parallel requests will all get 401 simultaneously
  • Resetting refreshPromise = null inside .then() instead of .finally(), which leaves the variable set when the refresh fails and permanently prevents future refreshes
  • Not retrying the original request after the token refresh succeeds, requiring the user to repeat whatever action they took
  • Not handling the case where the refresh token itself is expired or revoked, which can cause the 401 handler to call refreshTokenOnce() infinitely
  • Storing the refreshPromise in React state or a component variable, losing sharing across the many concurrent callers that may be in different components

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you explain why multiple parallel 401 responses cause multiple refresh requests without a shared Promise?
  • Can you implement the shared refreshPromise pattern with a finally reset?
  • Can you explain why finally resets the variable rather than then?
  • Can you describe how to queue and replay original failed requests after a successful refresh?
  • Can you describe what to do when the refresh itself returns a 401 or 403?

Summary

When an access token expires and multiple concurrent requests all receive a 401 simultaneously, a naive retry approach triggers one token refresh per failed request. With 10 concurrent requests this means 10 simultaneous refresh calls, which can cause the auth server to invalidate refresh tokens mid-flight and result in spurious logout.

The shared Promise pattern fixes this by storing the in-flight refresh as a module-level variable. Any request that detects a 401 checks whether a refresh is already in progress. If yes, it awaits the existing Promise rather than starting a new one. If no, it starts the refresh and assigns the Promise so other callers can share it. The finally block resets the variable unconditionally so the next token expiry can trigger a fresh cycle.

Extending the pattern with a queue of failed request callbacks allows all the original requests to be replayed with the new token after a successful refresh, providing a seamless experience where the user's actions complete successfully without any retry required from the application layer.

Frequently Asked Questions

Why do multiple refresh calls happen?

When parallel API calls all receive a 401 simultaneously, each tries to refresh the token independently unless you share a single refresh Promise.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement