Refreshing new access token using reacjs

Summary

The challenge is implementing a transparent token refresh mechanism in React that automatically handles expired access tokens without interrupting the user experience. The issue stems from managing asynchronous token state across concurrent API requests while preventing race conditions when multiple requests discover an expired token simultaneously. The solution requires a centralized HTTP client with an interceptor pattern that queues requests during refresh operations.

Root Cause

The primary failure points in typical React JWT refresh implementations are:

  • Race conditions on token expiration: When multiple parallel API calls encounter an expired token simultaneously, each triggers its own refresh request, overwhelming the backend and creating inconsistent token states
  • Missing request interception layer: Without a centralized HTTP client, each component handles token refresh independently, leading to code duplication and inconsistent error handling
  • Unmanaged promise chains: Failing to return the original request configuration from interceptors breaks the promise chain, causing requests to hang or fail silently
  • State synchronization issues: Storing tokens in multiple locations (component state, localStorage, context) creates desynchronization between what the UI believes is current and what the API actually requires
  • Infinite redirect loops: Improper error handling can cause the application to continuously attempt refresh when the refresh token itself is invalid

Why This Happens in Real Systems

This pattern emerges because developers often start with simple fetch calls and gradually add authentication requirements incrementally. Without an architectural blueprint, teams patch token handling into individual components rather than abstracting it into infrastructure.

The asynchronous nature of React exacerbates this: a user might trigger actions faster than token refresh completes, or the app might hydrate from localStorage with stale tokens while background refresh is in progress. JWTs are stateless by design, so the server cannot proactively notify clients of expiration, forcing clients to detect and react to 401 responses reactively.

In production, this creates cascading failures: one expired token triggers multiple refresh attempts, which can hit rate limits, causing legitimate requests to fail and creating a poor user experience where the app appears to randomly logout users.

Real-World Impact

  • User experience degradation: Users see unexpected logout screens or error messages, leading to support tickets and churn
  • System reliability issues: Token refresh storms can DDoS your own authentication service during peak usage
  • Data integrity risks: Failed requests due to token expiration can cause partial form submissions or data loss
  • Security vulnerabilities: Improper refresh token storage (e.g., in localStorage instead of httpOnly cookies) exposes the system to XSS attacks
  • Developer productivity loss: Teams spend significant time debugging authentication issues rather than building features

Example or Code

Here is a production-ready React implementation using Axios interceptors:

import axios from 'axios';
import { AuthRefreshError } from './errors';

// Centralized store for auth state
class AuthStore {
  constructor() {
    this.refreshPromise = null;
    this.accessToken = localStorage.getItem('access_token');
    this.refreshToken = localStorage.getItem('refresh_token');
  }

  getAccessToken() {
    return this.accessToken;
  }

  setTokens(access, refresh) {
    this.accessToken = access;
    this.refreshToken = refresh;
    localStorage.setItem('access_token', access);
    localStorage.setItem('refresh_token', refresh);
  }

  clearTokens() {
    this.accessToken = null;
    this.refreshToken = null;
    localStorage.removeItem('access_token');
    localStorage.removeItem('refresh_token');
  }

  // Singleton refresh operation
  async refreshToken() {
    // If a refresh is already in progress, return that promise
    if (this.refreshPromise) {
      return this.refreshPromise;
    }

    if (!this.refreshToken) {
      throw new AuthRefreshError('No refresh token available');
    }

    // Create new refresh promise
    this.refreshPromise = axios
      .post('/api/auth/refresh', { refresh_token: this.refreshToken })
      .then((response) => {
        const { access_token, refresh_token } = response.data;
        this.setTokens(access_token, refresh_token);
        return access_token;
      })
      .catch((error) => {
        // Refresh failed - logout
        this.clearTokens();
        window.location.href = '/login';
        throw new AuthRefreshError('Token refresh failed');
      })
      .finally(() => {
        // Clear promise so future requests can trigger new refresh
        this.refreshPromise = null;
      });

    return this.refreshPromise;
  }
}

export const authStore = new AuthStore();

// Create configured axios instance
export const apiClient = axios.create({
  baseURL: process.env.REACT_APP_API_URL,
  headers: {
    'Content-Type': 'application/json',
  },
});

// Request interceptor: attach token
apiClient.interceptors.request.use(
  (config) => {
    const token = authStore.getAccessToken();
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// Response interceptor: handle token refresh
apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    // If error is not 401 or request was already retried, reject
    if (error.response?.status !== 401 || originalRequest._retry) {
      return Promise.reject(error);
    }

    // Mark request as retried
    originalRequest._retry = true;

    try {
      // Attempt token refresh
      const newAccessToken = await authStore.refreshToken();

      // Update headers and retry original request
      originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
      return apiClient(originalRequest);
    } catch (refreshError) {
      // Refresh failed, reject with original error
      return Promise.reject(error);
    }
  }
);

// Usage in components
export const fetchUserData = async (userId) => {
  try {
    const response = await apiClient.get(`/users/${userId}`);
    return response.data;
  } catch (error) {
    if (error instanceof AuthRefreshError) {
      // Token refresh failed, already redirected to login
      return null;
    }
    throw error;
  }
};

How Senior Engineers Fix It

Seniors design for failure modes first, not happy paths:

  1. Implement idempotent request retry: The _retry flag prevents infinite loops while allowing exactly one automatic retry per request
  2. Singleton refresh pattern: Store the refresh promise on the store so concurrent requests share a single refresh operation, eliminating token race conditions
  3. Separate auth state management: Isolate token logic in a dedicated class rather than scattering it across components, making it testable and observable
  4. Defensive error boundaries: Wrap refresh logic in try/catch and handle 401 vs. 403 distinctions—403 means refresh token is invalid, requiring immediate logout
  5. Observable metrics: Add logging around token refresh events to monitor refresh rates and detect anomalies
  6. Graceful degradation: Provide clear user feedback during refresh rather than silent failures
  7. Token lifecycle validation: Implement token expiration checking before requests to avoid unnecessary network calls

Why Juniors Miss It

Juniors typically approach this problem reactively rather than architecturally:

  • They solve the immediate problem (“this one call needs a new token”) rather than the systemic one (“all calls need transparent refresh”)
  • Missing async expertise: Underestimating the complexity of coordinating multiple promises across concurrent operations
  • State management gaps: Storing tokens in component state instead of centralized, persistent storage
  • Interceptor unawareness: Not knowing that HTTP clients have built-in interception capabilities
  • Edge case blindness: Testing only the happy path and missing scenarios like rapid-fire requests, network failures, or refresh token expiration
  • Over-reliance on libraries: Assuming axios-auth-refresh or similar packages handle everything without understanding the underlying mechanics

The key insight is that authentication is infrastructure, not application logic. It deserves the same architectural rigor as database connections or state management.