Fix Axios Token Refresh Race Conditions

When multiple asynchronous HTTP requests expire simultaneously, applications often fire multiple refresh token requests at once, leading to race conditions, token rotation failures, and unexpected user logouts. The industry-standard solution to this problem is implementing an Axios response interceptor that uses a request queue alongside a single mutex-like execution flag. This ensures that only the first expired request initiates the token refresh, while all subsequent concurrent requests are paused and queued until the new access token is acquired and ready for replay.

The Race Condition Problem

In modern web applications using access and refresh tokens (often with refresh token rotation), a client might fire three or four API requests concurrently on a dashboard view. If the access token has expired, every single request returns a 401 Unauthorized status at roughly the same millisecond.

Without proper synchronization, the client application will attempt to call the refresh endpoint once for each failed request. If the authentication backend enforces single-use refresh token rotation, the first refresh call succeeds and invalidates the old refresh token. The subsequent refresh calls will then fail, causing the backend to flag a potential token reuse attack and immediately revoke the user's session entirely.

The Solution: Interceptors and a Subscriber Queue

To handle concurrent 401 errors safely, the Axios instance must coordinate retries using three core elements:

  1. A State Flag (isRefreshing): Tracks whether a token refresh is currently in progress.
  2. A Failed Request Queue: An array storing the promise resolution callbacks of all requests that failed with a 401 while the refresh operation was running.
  3. An Axios Response Interceptor: Intercepts 401 errors, delegates the refresh call, pauses secondary requests, and retries all queued requests once the new token is available.

Implementation

Below is a complete, production-ready implementation of a synchronized token refresh mechanism using Axios:

import axios from 'axios';

const apiClient = axios.create({
  baseURL: 'https://api.example.com',
});

let isRefreshing = false;
let failedQueue = [];

const processQueue = (error, token = null) => {
  failedQueue.forEach((prom) => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(token);
    }
  });

  failedQueue = [];
};

apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    // Reject immediately if error is not 401 or request was already retried
    if (error.response?.status !== 401 || originalRequest._retry) {
      return Promise.reject(error);
    }

    // Handle token refresh endpoint failure specifically to avoid infinite loops
    if (originalRequest.url === '/auth/refresh') {
      return Promise.reject(error);
    }

    if (isRefreshing) {
      // If a refresh is already in progress, enqueue this request
      return new Promise((resolve, reject) => {
        failedQueue.push({ resolve, reject });
      })
        .then((token) => {
          originalRequest.headers['Authorization'] = `Bearer ${token}`;
          return apiClient(originalRequest);
        })
        .catch((err) => {
          return Promise.reject(err);
        });
    }

    originalRequest._retry = true;
    isRefreshing = true;

    try {
      // Call the refresh endpoint
      const response = await axios.post('https://api.example.com/auth/refresh', {}, {
        withCredentials: true // If using HTTP-only cookies
      });

      const newToken = response.data.accessToken;

      // Update default headers for future requests
      apiClient.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
      originalRequest.headers['Authorization'] = `Bearer ${newToken}`;

      // Resolve all pending requests in the queue
      processQueue(null, newToken);

      // Retry the original request that triggered the refresh
      return apiClient(originalRequest);
    } catch (refreshError) {
      // If refresh fails, reject all queued requests and trigger logout
      processQueue(refreshError, null);
      
      // Optional: Dispatch a global logout event or clear storage
      // window.location.href = '/login';

      return Promise.reject(refreshError);
    } finally {
      isRefreshing = false;
    }
  }
);

export default apiClient;

Key Execution Steps

  1. Detection: When a request receives a 401 Unauthorized status, the response interceptor catches it. The _retry flag ensures that a request is never retried more than once, preventing infinite retry loops.
  2. Queuing: If isRefreshing is true, a new Promise is returned. Its resolve and reject functions are pushed into failedQueue. Execution halts here for this specific request.
  3. Execution: If isRefreshing is false, the flag is immediately flipped to true, and the refresh API call is executed using an independent Axios call (avoiding the interceptor).
  4. Resolution: Upon a successful refresh, processQueue iterates over all queued callbacks, resolving them with the new token. Each queued request then updates its Authorization header and replays via apiClient(originalRequest).
  5. Cleanup: In the finally block, isRefreshing is reset to false. If the refresh fails, all queued promises are rejected, ensuring that pending operations terminate cleanly and the application can safely redirect to the login page.