Fixing Multiple 401 Errors in Axios: The Best Way to Handle Token Expiry


Issue and Context
I recently encountered a frustrating problem in my project. When the access token expired, multiple API calls would fail with 401 Unauthorized errors, leading to a stream of "Something went wrong" messages. Even though we had a refresh token mechanism in place, since multiple APIs were called simultaneously, they all failed before the token could be refreshed. This resulted in unnecessary server load and a poor user experience.
Why This Matters
Authentication tokens are crucial for securing your application - they're included in every authorized API call after login. Getting token refresh right is essential for maintaining a smooth user experience.
The Challenge
While I found many examples showing how to handle token refresh for a single request, they didn't address the real-world scenario where multiple API calls are made simultaneously. Simply refreshing tokens for each failed request isn't efficient.
Ideal Workflow
When the first request hits a 401 error, initiate a token refresh
Hold any subsequent requests that get 401 errors while the refresh is in progress
After getting new tokens, retry the original request. If it still fails with 401, assume the tokens are invalid and log the user out
If the first retry succeeds, then retry all the waiting requests with the new token
Technical Background
Let's look at the tools we're working with:
Axios
A popular HTTP client library for browsers and Node.js that provides a cleaner interface than the native fetch API. One of its powerful features is interceptors.
Axios Interceptors
These are middleware functions that let you globally intercept and modify HTTP requests and responses. They look like this:
javascriptCopyaxios.interceptors.request.use(
(config) => {
// Pre-request logic
return config;
},
(error) => Promise.reject(error)
);
axios.interceptors.response.use(
(response) => response,
(error) => Promise.reject(error)
);
Access and refresh tokens
Access and refresh tokens work as a pair in web security - access tokens provide short-term resource access while refresh tokens let you get new access tokens without re-authentication.
Potential Solutions
Request Queue Approach: Since JavaScript is asynchronous, multiple API calls on page load can hit 401s before the token refresh completes. While implementing a request queue seems logical, it can lead to unnecessary waiting times and might break under timeout conditions.
Proactive Token Refresh: We could store the token's expiration time and refresh it in the background before it expires. However, this fails if the user hasn't accessed the app for a while - the token would be expired before any refresh attempt.
AbortController with Promise-based Solution: This is what we've implemented in my project. When a 401 occurs, we use AbortController to cancel pending requests and return empty promises to prevent further API calls. The key improvement is using a single shared Promise for the refresh process - this ensures all concurrent requests wait for the same token refresh operation and retry together once it's complete.
// Global refresh token promise let tokenRefreshPromise = null; // Utility functions const isUnauthorizedError = (error) => error?.response?.status === 401; const handleLogout = () => { localStorage.clear(); // Notify native apps about logout try { window.location.href('/login') // redirects to login } catch (error) { console.warn('Failed to notify native app about logout:', error); } }; const mainBackendRequestInterceptor = async (config) => { const controller = new AbortController(); const token = localStorage.getItem('access_token'); // Handle missing token if (!token) { controller.abort('No auth token available'); return new Promise(() => {}); } // Check token expiration const tokenExpiration = localStorage.getItem('access_token_expiration'); const currentTime = Date.now(); const expirationTime = new Date(tokenExpiration).getTime(); if (tokenExpiration && currentTime > expirationTime) { try { // Use the global refresh promise if it exists, or create a new one if (!tokenRefreshPromise) { tokenRefreshPromise = refreshAccessToken(); } await tokenRefreshPromise; // Get the new token after refresh const newToken = localStorage.getItem('access_token'); config.headers.Authorization = `Bearer ${newToken}`; } catch (error) { handleLogout(); return Promise.reject(error); } finally { tokenRefreshPromise = null; } } // Set request headers config.headers = { Authorization: `Bearer ${token}`, Accept: 'application/json', ...config.headers }; return { ...config, signal: controller.signal }; }; const mainBackendResponseInterceptor = (response) => response; const mainBackendResponseErrorInterceptor = async (error) => { // Handle cancelled requests if (error?.name === 'CanceledError') { return new Promise(() => {}); } const originalRequest = error?.config; const token = localStorage.getItem('access_token'); // If no token or not a 401 error, reject immediately if (!token || !isUnauthorizedError(error)) { return Promise.reject(error); } try { // Use existing refresh promise or create new one if (!tokenRefreshPromise) { tokenRefreshPromise = refreshAccessToken(); } // Wait for token refresh const [newToken, newRefreshToken] = await tokenRefreshPromise; // Update tokens in storage localStorage.setItem('access_token', newToken); localStorage.setItem('refresh_token', newRefreshToken); // Update the original request headers originalRequest.headers.Authorization = `Bearer ${newToken}`; // Retry the original request try { return await mainBackendInstance.request(originalRequest); } catch (retryError) { // If retry still fails with 401, tokens are invalid if (isUnauthorizedError(retryError)) { handleLogout(); return Promise.reject(retryError); } return Promise.reject(retryError); } } catch (refreshError) { handleLogout(); return Promise.reject(refreshError); } finally { tokenRefreshPromise = null; } }; // Create and configure axios instance const mainBackendInstance = axios.create(); // Apply interceptors mainBackendInstance.interceptors.request.use(mainBackendRequestInterceptor); mainBackendInstance.interceptors.response.use( mainBackendResponseInterceptor, mainBackendResponseErrorInterceptor ); export default mainBackendInstance;
Detailed Explanation
Using interceptors, When the first request encounters a 401 error, it initiates a tokenRefreshPromise
and begins the refresh process. Any concurrent requests that also receive 401 errors will detect the existing tokenRefreshPromise
and wait for it to complete rather than initiating new refresh attempts. Once the refresh succeeds, all waiting requests receive the new token and retry their original requests, after which the tokenRefreshPromise
is cleared to prepare for future refresh cycles. If the refresh process fails or if any retried request still returns a 401, all pending requests fail together, the user is logged out, and tokens are cleared from storage
The final solution prevents race conditions by ensuring only one token refresh happens at a time, while keeping all requests in sync - they either all succeed with the new token or fail gracefully together.
In my case, if both the access and refresh tokens expire or fail, I log the user out.
However, if you don’t want to force a logout, you can eject the interceptors when the refresh token fails and reattach them once a new token is retrieved. This ensures smooth handling of future requests.
You can read more about it here: Stack Overflow
Have a suggestion or improvement?
Please feel free to comment below and let me know your thoughts, so I can continue to improve!
Happy coding, and follow for more!
Connect with me on LinkedIn: Abhishek Bhatt
Subscribe to my newsletter
Read articles from Abhishek bhatt directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
