Create your own
Lesson illustration

Handling Concurrent Token Refreshes

Hello! Let's dive into the next level of session management.

In our last lesson, we built a robust client-side interceptor that automatically refreshes an expired access token and retries a single failed request. This is a fantastic foundation, but it has a subtle flaw that can surface in complex, data-rich applications.

Today, we will address that flaw. This lesson focuses on a common and critical challenge in authentication: managing concurrent API requests when the access token expires. Our goal is to develop a strategy that prevents multiple, simultaneous refresh attempts, ensuring our application remains efficient and error-free.

By the end of this lesson, you will be able to enhance our axios interceptor to gracefully handle multiple concurrent requests during a token refresh, ensuring the refresh operation happens only once.

1. The Race Condition Problem

Our current interceptor works perfectly if API requests happen one after another. But what happens when a user navigates to a new page and the application fires off several requests at once to populate different components (e.g., fetching user profile, notifications, and dashboard data)?

If the access token has expired, all these requests will fail at roughly the same time with a 401 Unauthorized error. Our current interceptor, attached to each request, will see the 401 and each one will independently trigger a call to our /api/auth/refresh endpoint.

This creates a race condition with several negative consequences:

  • Inefficiency: We're making multiple identical, unnecessary API calls to the refresh endpoint.
  • Potential Errors: If your backend uses refresh token rotation (where a new refresh token is issued and the old one is invalidated upon use), only the first refresh request will succeed. All others will fail, causing their corresponding original requests to fail as well.
  • Server Load: It places unnecessary load on your authentication service.

The article below clearly describes this exact scenario.

Handling refresh token for multiple requests using React

This article, 'Handling refresh token for multiple requests using React', gets straight to the point of the problem we're solving today.

Please read the 'Problem' section. It perfectly illustrates the scenario with a network tab screenshot, showing three requests failing, one refresh, and three successful retries. This is the exact behavior we want to achieve.

2. The Solution: A "Singleton Promise" Lock

To solve this, we need to ensure that only the first request that fails with a 401 error is responsible for initiating the token refresh. All other concurrent requests that also fail should simply "wait" for the first one to finish and then use the new token it provides.

A clean and effective way to implement this is with a promise-based lock. The strategy is as follows:

  1. Create a shared "lock" variable: We'll use a variable, accessible within our interceptor, to hold the promise returned by the refresh token function. Let's call it refreshTokenPromise. Initially, it will be null or undefined.
  2. The First Failed Request: When the first 401 error occurs, the interceptor checks refreshTokenPromise. Since it's null, this request knows it's the "leader." It calls the refresh() function and assigns the returned promise to refreshTokenPromise. It then awaits this promise.
  3. Concurrent Failed Requests: While the first request is awaiting the refresh, other requests might also fail with a 401. Their interceptors will also check refreshTokenPromise. This time, it's not null; it holds the pending promise from the first request. These "follower" requests simply await this existing promise. They don't trigger a new refresh.
  4. Resolution: Once the refresh promise resolves, all waiting requests (the leader and the followers) will receive the new access token. They can then update their headers and retry their original requests.
  5. Cleanup: It is absolutely critical to reset refreshTokenPromise back to null after the refresh is complete (whether it succeeded or failed). A finally block is the perfect tool for this, ensuring the lock is always released for the next batch of expirations.

This approach effectively serializes the refresh logic while allowing the initial API calls to remain concurrent.

3. Implementing the Concurrent-Safe Interceptor

Let's modify the useAxiosPrivate hook we built in the last lesson to incorporate this locking mechanism. The core logic will still reside inside the useEffect hook, but we'll declare our lock variable outside of it to create a shared state across interceptor invocations.

The following article provides a concise code example of this exact pattern.

Handling refresh token for multiple requests using React

Let's look at the 'Interceptor Solution' section from the same article. It provides a clear and compact implementation of the promise-based lock directly within an Axios interceptor.

Focus on the code block under 'Interceptor Solution' and the accompanying explanation. Notice the refreshingFunc variable (our refreshTokenPromise) declared outside the interceptor and how it's used to prevent subsequent calls to renewToken().

Now, let's integrate this improved logic into our useAxiosPrivate.ts hook.

// src/hooks/useAxiosPrivate.ts

import { useEffect } from 'react';
import { api } from '../lib/axios';
import { useRefreshToken } from './useRefreshToken';
import { useAuth } from './useAuth';

// This variable will hold the promise for the token refresh.
// It's defined outside the hook to act as a singleton across the app.
let refreshTokenPromise: Promise<string> | null = null;

export const useAxiosPrivate = () => {
  const refresh = useRefreshToken();
  const { auth } = useAuth();

  useEffect(() => {
    const requestIntercept = api.interceptors.request.use(
      config => {
        if (!config.headers['Authorization']) {
          config.headers['Authorization'] = `Bearer ${auth?.accessToken}`;
        }
        return config;
      },
      error => Promise.reject(error)
    );

    const responseIntercept = api.interceptors.response.use(
      response => response,
      async (error) => {
        const originalRequest = error.config;
        
        // Check for 401 and that this is not a retry request
        if (error.response?.status === 401 && !originalRequest._retry) {
          originalRequest._retry = true;

          // If a refresh is not already in progress, start one.
          if (!refreshTokenPromise) {
            refreshTokenPromise = refresh().finally(() => {
              // Reset the promise lock once the refresh is complete.
              refreshTokenPromise = null;
            });
          }

          try {
            // Wait for the single refresh to complete.
            const newAccessToken = await refreshTokenPromise;
            
            // Update the header and retry the original request.
            originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
            return api(originalRequest);
          } catch (refreshError) {
            // If the refresh itself fails, we should log the user out.
            // This will be handled by your auth context or a redirect.
            console.error("Refresh token failed, logging out.", refreshError);
            // Example: auth.logout();
            return Promise.reject(refreshError);
          }
        }
        return Promise.reject(error);
      }
    );

    return () => {
      api.interceptors.request.eject(requestIntercept);
      api.interceptors.response.eject(responseIntercept);
    };
  }, [auth, refresh]);

  return api;
};

The beauty of this solution is its encapsulation. No component that uses useAxiosPrivate needs to be aware of this complex logic. They continue to fire off API requests as needed, and the interceptor handles the session management seamlessly in the background.

4. An Alternative Strategy: The Request Queue

While the "singleton promise" is elegant, another common pattern is to use a request queue. The logic is slightly different:

  1. The first 401 error triggers the refresh and sets a flag, isRefreshing = true.
  2. Any subsequent requests that fail while isRefreshing is true are not retried immediately. Instead, they are pushed into a queue (an array of functions that resolve the original request's promise).
  3. Once the token refresh is successful, isRefreshing is set to false, and all functions in the queue are executed, effectively retrying the failed requests.

This approach is also very robust and is used by popular libraries like axios-auth-refresh. While we won't implement it today, it's valuable to know it exists as an alternative architectural pattern for solving the same problem. The article "Bulletproofing Your React App" (ID: ef04f) alludes to this more managed approach, which can be useful if you need even finer-grained control over pausing and resuming requests.

Conclusion

You have now "bulletproofed" your client-side session management against race conditions from concurrent requests. This is a professional-grade pattern that ensures a smooth user experience and an efficient, predictable client-server interaction.

Key Takeaways:

  • The Problem: Concurrent API calls with an expired token can trigger multiple, unnecessary refresh requests, leading to inefficiency and errors.
  • The "Singleton Promise" Solution: By using a shared promise as a lock, we ensure only the first failed request triggers a refresh, while others await its result.
  • Critical Cleanup: The lock (our promise variable) must be reset in a finally block to ensure the mechanism is ready for the next token expiration event.
  • Encapsulation is Key: This complex logic is perfectly contained within the useAxiosPrivate hook, requiring no changes to the components that consume it.

What's Next?

With our token refresh logic now robust and secure, the final piece of the session management puzzle is handling logout. In the next lesson, we will implement a secure logout mechanism that invalidates both the access and refresh tokens on the server, ensuring a user's session is terminated completely.

Can't find a good explanation? Sign up and we'll make it for you

Sign up