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

Abhishek bhattAbhishek bhatt
5 min read

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

  1. When the first request hits a 401 error, initiate a token refresh

  2. Hold any subsequent requests that get 401 errors while the refresh is in progress

  3. After getting new tokens, retry the original request. If it still fails with 401, assume the tokens are invalid and log the user out

  4. 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

  1. 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.

  2. 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.

  3. 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

0
Subscribe to my newsletter

Read articles from Abhishek bhatt directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Abhishek bhatt
Abhishek bhatt