Concurrent Token Refresh with Axios Interceptors

Handling concurrent token refresh requests in an application prevents race conditions when multiple API calls fail simultaneously with a 401 Unauthorized status. The most effective pattern uses Axios response interceptors combined with an execution queue and a locking flag. This ensures that only a single refresh token request is sent to the authorization server while all subsequent failed requests are queued and automatically retried once the new access token is acquired.

The Race Condition Problem

When a user's access token expires and multiple asynchronous API calls fire at the same time, each request will return an HTTP 401 error. If each failed request triggers its own refresh endpoint call, multiple refresh requests are executed concurrently. This can invalidate refresh tokens prematurely, cause rate-limiting issues, or desynchronize client authentication state.

The Solution: Queue-Based Interceptor Pattern

The optimal approach utilizes two primary mechanisms:

  1. A locking flag (isRefreshing): Tracks whether a token refresh is currently in flight.
  2. A subscriber queue (failedQueue): Holds promises for incoming requests that fail while the refresh is pending.

Implementation

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 = [];
};

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

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

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

    originalRequest._retry = true;
    isRefreshing = true;

    return new Promise(async (resolve, reject) => {
      try {
        const refreshToken = localStorage.getItem('refreshToken');
        
        // Execute the refresh call using a clean instance to prevent infinite loops
        const { data } = await axios.post('https://api.example.com/auth/refresh', {
          refreshToken,
        });

        const newAccessToken = data.accessToken;
        localStorage.setItem('accessToken', newAccessToken);

        // Update default headers and retry the original request
        apiClient.defaults.headers.common['Authorization'] = `Bearer ${newAccessToken}`;
        originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;

        processQueue(null, newAccessToken);
        resolve(apiClient(originalRequest));
      } catch (refreshError) {
        processQueue(refreshError, null);
        
        // Clear local storage and redirect to login if refresh fails
        localStorage.removeItem('accessToken');
        localStorage.removeItem('refreshToken');
        window.location.href = '/login';

        reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    });
  }
);

export default apiClient;

Key Considerations