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:
- A locking flag (
isRefreshing): Tracks whether a token refresh is currently in flight. - 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
- Separate Axios Instance for Refreshing: Always
execute the refresh token call using a separate
axiosinstance or standard call without interceptors to avoid recursive 401 loops if the refresh token itself is invalid. - Custom Retry Flag (
_retry): Setting a custom property onoriginalRequestprevents the original request from being retried more than once if it continues to fail. - Comprehensive Error Handling: If the refresh
operation fails,
processQueuerejects all queued promises with the refresh error, and the application state is cleared to enforce re-authentication.