Handle Dynamic Token Expiration with Axios Queue

Managing dynamic bearer token expiration in modern web applications requires a robust mechanism to intercept expired authentication tokens, pause outgoing requests, refresh credentials, and replay failed calls seamlessly. When a token expires, concurrent API calls often trigger multiple simultaneous refresh requests, causing race conditions and unauthorized errors. By leveraging Axios interceptors combined with an asynchronous request queue and a locking state, you can ensure that only a single token refresh request executes while all subsequent requests wait in line to be replayed with the new token.

The Core Problem

When an access token expires, any API request sent by the client returns an HTTP 401 Unauthorized status. If a dashboard makes ten parallel requests upon loading, all ten requests might fail simultaneously. If each failed request independently tries to refresh the token, the backend receives ten refresh requests using the same refresh token, which often invalidates the session due to refresh token rotation policies.

The Queue-Based Solution Pattern

The solution uses Axios response interceptors alongside three state variables:

Step-by-Step Implementation

  1. Create an Axios Instance: Configure an instance with default headers.
  2. Setup the Interceptor: Listen for 401 errors in the response interceptor.
  3. Queue Incoming Requests: If isRefreshing is true, wrap the failed request in a promise, push it to failedQueue, and wait.
  4. Acquire Lock and Refresh: If isRefreshing is false, set it to true, call the refresh endpoint, update the authentication headers/storage, and process the queued requests.
  5. Handle Failures: If the refresh token is also invalid or expired, reject all queued requests, clear the queue, and redirect the user to login.

Implementation Code

import axios from 'axios';

const apiClient = axios.create({
  baseURL: 'https://api.example.com',
  headers: {
    'Content-Type': 'application/json',
  },
});

let isRefreshing = false;
let failedQueue = [];

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

  failedQueue = [];
};

// Request Interceptor: Attach Current Token
apiClient.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('accessToken');
    if (token) {
      config.headers['Authorization'] = `Bearer ${token}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

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

    // Check if error is 401 and request has not already been retried
    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // Queue the request if a refresh is already in progress
        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;

      try {
        const refreshToken = localStorage.getItem('refreshToken');
        const response = await axios.post('https://api.example.com/auth/refresh', {
          refreshToken,
        });

        const { accessToken, newRefreshToken } = response.data;

        localStorage.setItem('accessToken', accessToken);
        if (newRefreshToken) {
          localStorage.setItem('refreshToken', newRefreshToken);
        }

        apiClient.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
        originalRequest.headers['Authorization'] = `Bearer ${accessToken}`;

        // Release queued requests with new token
        processQueue(null, accessToken);

        return apiClient(originalRequest);
      } catch (refreshError) {
        // Refresh token failed: purge queue and log user out
        processQueue(refreshError, null);
        localStorage.removeItem('accessToken');
        localStorage.removeItem('refreshToken');
        window.location.href = '/login';
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

export default apiClient;

Critical Considerations