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:
isRefreshing(Boolean): A flag acting as a mutex lock to indicate whether a refresh request is currently in progress.failedQueue(Array): A queue holding the promises of pending requests that arrived while the token was being refreshed.processQueue(Function): A helper that iterates over thefailedQueueto either resolve or reject all pending requests once the refresh completes or fails.
Step-by-Step Implementation
- Create an Axios Instance: Configure an instance with default headers.
- Setup the Interceptor: Listen for
401errors in the response interceptor. - Queue Incoming Requests: If
isRefreshingis true, wrap the failed request in a promise, push it tofailedQueue, and wait. - Acquire Lock and Refresh: If
isRefreshingis false, set it to true, call the refresh endpoint, update the authentication headers/storage, and process the queued requests. - 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
- Retry Flag (
_retry): Ensure every original request is marked with a custom boolean like_retry = trueto prevent infinite loops if the retried request still returns a401. - Handling Refresh Token Expiration: Always ensure that an error during the refresh step clears the queue and destroys the session to prevent memory leaks and repeated failing requests.
- Race Condition Prevention: The check
if (isRefreshing)must occur synchronously before any asynchronous calls in the interceptor to guarantee atomic queue insertion.