Handling Token Refresh in Frontend Applications

7 July, 2025

Authentication tokens are the foundation of modern web applications, but they come with a challenge: they expire. When a user is actively using your application and their access token expires, you need to refresh it smoothly without interrupting their experience. In this guide, we'll explore how to build a solid token refresh system using Axios interceptors and cookies.

The Problem: Token Expiration in Multi-Service Applications

Modern applications often talk to multiple backend services. Each service requires authentication, and when tokens expire, multiple API calls can fail at the same time. The simple approach of refreshing tokens for each failed request creates several problems:

  • Race Conditions: Multiple refresh attempts running at the same time
  • Infinite Loops: Refresh requests themselves getting caught in retry logic
  • Poor User Experience: Users seeing multiple login redirects
  • Resource Waste: Unnecessary API calls to refresh endpoints

Understanding the Solution Architecture

Our solution uses a shared refresh promise approach with Axios response interceptors. Here's the high-level strategy:

  • Single Refresh Promise: Only one token refresh happens at a time
  • Request Coordination: Multiple requests wait for the same refresh to complete
  • Automatic Retry: Failed requests automatically retry with new tokens
  • Graceful Degradation: Proper cleanup and user redirection on refresh failure

Server-Side Cookie Management

Since the server automatically handles all cookie operations (setting, updating, and deleting), we don't need any client-side cookie manipulation functions. The server will:

  • Set secure cookies when tokens are issued
  • Update cookies when tokens are refreshed
  • Delete cookies when authentication fails or logout occurs

This approach is more secure because cookies can be set as HttpOnly, preventing JavaScript access and reducing XSS risks.

Setting Up the Foundation

Next, let's establish our Axios service instances. In a microservices architecture, you typically have multiple backend services:

import axios from "axios";
import { envObj } from "./envs";

// Create separate Axios instances for different services
export const authService = axios.create({
  baseURL: envObj.authAPI,
  withCredentials: true,
});

export const projectService = axios.create({
  baseURL: envObj.projectAPI,
  withCredentials: true,
});

export const workerService = axios.create({
  baseURL: envObj.workerAPI,
  withCredentials: true,
});

// Add more services as needed...

Each service gets its own Axios instance with a specific base URL. The withCredentials: true setting ensures cookies are sent with requests, which is important for authentication.

Core State Management

The heart of our solution lies in two key state variables:

let isRefreshing = false;
let refreshPromise: Promise<string | null> | null = null;
  • isRefreshing: A boolean flag that prevents multiple simultaneous refresh attempts. When the first request encounters a 401 error, this flag is set to true, signaling to subsequent requests that a refresh is already in progress.
  • refreshPromise: Stores the actual refresh promise. This allows multiple requests to await the same refresh operation instead of starting their own.

The Token Refresh Function

The core refresh logic handles the actual token renewal:

const refreshToken = async (): Promise<string | null> => {
  try {
    // Make refresh API call - server will handle all cookie operations
    const response = await authService.post("/auth/refresh");

    // Extract new access token from response for immediate use
    const newToken = response.data.data?.access_token;

    if (!newToken) {
      throw new Error("Invalid token response");
    }

    // Server automatically sets new cookies via Set-Cookie headers
    // and deletes old ones as needed
    return newToken;
  } catch (error) {
    console.error("Token refresh failed:", error);
    clearAuthAndRedirect();
    return null;
  }
};

Key Points in the Refresh Function:

  • Simplified API Call: Just makes a POST request to /auth/refresh endpoint
  • Token Extraction: Safely extracts new access token from response
  • Server-Side Cookie Management: Server automatically handles all cookie operations
  • Error Handling: Clears authentication and redirects on any failure

Cleanup and Redirection Logic

When authentication fails completely, we need to redirect the user:

const clearAuthAndRedirect = () => {
  // Server will handle cookie deletion when we redirect to login
  // or when the refresh endpoint fails
  window.location.href = "/login";
};

This function redirects the user to the login page. The server will handle clearing any authentication cookies during the login process or when the refresh fails.

The Response Interceptor: Where the Magic Happens

The response interceptor is where we handle 401 errors and coordinate token refresh:

const services = [authService, projectService, workerService];

services.forEach((service) => {
  service.interceptors.response.use(
    (response) => response, // Success responses pass through unchanged
    async (error: AxiosError) => {
      const originalRequest = error.config as InternalAxiosRequestConfig & {
        _retry?: boolean;
        skipAuthRefresh?: boolean;
      };

      // Skip processing for special cases
      if (
        !originalRequest ||
        originalRequest.url?.includes("/auth/refresh") ||
        originalRequest.skipAuthRefresh
      ) {
        return Promise.reject(error);
      }

      // Handle 401 errors
      if (error.response?.status === 401) {
        // ... token refresh logic (detailed below)
      }

      return Promise.reject(error);
    }
  );
});

Special Case Handling

Before processing 401 errors, we check for special cases:

  • Missing config: If there's no request configuration, we can't retry
  • Refresh endpoint: Prevents infinite loops by skipping refresh requests
  • Skip flag: Allows manual override with skipAuthRefresh: true

The Two-Path Flow: First Request vs Concurrent Requests

Here's where our solution shines. We handle the first request differently from concurrent requests:

Path 1: First Request (Token Refresh Initiator)

// Handle 401 errors with proper flow control
if (!originalRequest._retry) {
  originalRequest._retry = true;

  if (!isRefreshing) {
    // First request - initiate refresh
    isRefreshing = true;
    refreshPromise = refreshToken();

    try {
      const newToken = await refreshPromise;
      if (!newToken) return Promise.reject(error);

      // Update original request header and retry
      originalRequest.headers.Authorization = `Bearer ${newToken}`;
      return service(originalRequest);
    } catch (refreshError) {
      return Promise.reject(refreshError);
    } finally {
      isRefreshing = false;
      refreshPromise = null;
    }
  }
}

Step-by-step breakdown:

  1. Set flags: isRefreshing = true and _retry = true prevent further processing
  2. Create promise: Store the refresh promise for other requests to use
  3. Await refresh: Wait for the new token
  4. Update headers: Add the new token to the original request
  5. Retry request: Execute the original request with the new token
  6. Cleanup: Reset flags regardless of success or failure

Path 2: Concurrent Requests (Token Refresh Waiters)

else if (isRefreshing && refreshPromise) {
  // Concurrent request - wait for existing refresh
  try {
    const newToken = await refreshPromise;
    if (!newToken) return Promise.reject(error);

    originalRequest.headers.Authorization = `Bearer ${newToken}`;
    return service(originalRequest);
  } catch (refreshError) {
    return Promise.reject(refreshError);
  }
}

Step-by-step breakdown:

  1. Check state: If refresh is in progress, wait for it
  2. Await promise: Wait for the same refresh promise the first request created
  3. Update headers: Add the new token to this request
  4. Retry request: Execute this request with the new token
  5. Error handling: If refresh failed, reject this request too

Complete Flow Visualization

Let's trace through a complete scenario where three API calls happen simultaneously with expired tokens:

Time 0: User tokens expire

Time 1: Request A (GET /projects) → 401 Unauthorized
├── isRefreshing = false
├── Set isRefreshing = true, _retry = true
├── Create refreshPromise = refreshToken()
├── Wait for refresh...

Time 2: Request B (GET /users) → 401 Unauthorized
├── isRefreshing = true && refreshPromise exists
├── await refreshPromise (waits for same refresh)
├── Will retry when refresh completes

Time 3: Request C (POST /analytics) → 401 Unauthorized
├── isRefreshing = true && refreshPromise exists
├── await refreshPromise (waits for same refresh)
├── Will retry when refresh completes

Time 4: refreshPromise resolves with newToken
├── Request A: Update headers, retry → Success
├── Request B: Update headers, retry → Success
├── Request C: Update headers, retry → Success
├── isRefreshing = false, refreshPromise = null
└── All requests complete successfully

Error Handling Scenarios

Scenario 1: Refresh Token Missing or Invalid

// Server handles validation of refresh token from cookies
const response = await authService.post("/auth/refresh");

Result: If refresh token is missing or invalid, server returns error, triggering cleanup and redirect to login

Scenario 2: Refresh API Call Fails

try {
  const response = await authService.post("/auth/refresh", formData);
  // ... token extraction
} catch (error) {
  console.error("Token refresh failed:", error);
  clearAuthAndRedirect();
  return null;
}

Result: All waiting requests receive the same error, user redirected to login

Scenario 3: Invalid Token Response

const newToken = response.data.data?.access_token;
if (!newToken) {
  throw new Error("Invalid token response");
}

Result: Treated as refresh failure, cleanup and redirect


Advanced Considerations

Memory Management

Our approach is memory-efficient because:

  • No growing queues to manage
  • Shared promise prevents duplicate refresh calls
  • Automatic cleanup in finally blocks

Performance Optimization

The solution minimizes API calls by:

  • Single refresh request regardless of concurrent failures
  • Immediate retry without additional delay
  • No polling or interval-based checks

Security Considerations

  • Cookie Security: Server manages secure, HttpOnly cookies that JavaScript cannot access
  • Automatic Cookie Management: Server handles setting, updating, and deleting cookies
  • Refresh Token Rotation: Server can implement refresh token rotation seamlessly
  • Request Validation: Skip auth refresh for public endpoints

Common Pitfalls and Solutions

Pitfall 1: Infinite Refresh Loops

Problem: Refresh endpoint returns 401, triggering another refresh
Solution: Skip auth refresh for refresh endpoints

if (originalRequest.url?.includes("/auth/refresh")) {
  return Promise.reject(error);
}

Pitfall 2: Race Conditions

Problem: Multiple refresh attempts running simultaneously
Solution: Shared refresh promise pattern

if (isRefreshing && refreshPromise) {
  const newToken = await refreshPromise;
  // Use the same refresh result
}

Pitfall 3: Memory Leaks

Problem: Promises not properly cleaned up
Solution: Always clean up in finally blocks

finally {
  isRefreshing = false;
  refreshPromise = null;
}

Conclusion

This token refresh implementation provides a robust, efficient solution for handling token expiration in modern web applications. By using a shared refresh promise pattern with Axios interceptors, we achieve:

  • Seamless User Experience: Users never see authentication errors
  • Efficient Resource Usage: Single refresh request handles multiple failures
  • Reliable Error Handling: Proper cleanup and user redirection
  • Maintainable Code: Clean separation of concerns
  • Better Security: Using secure cookies instead of localStorage (server manages cookie security)

The key insight is treating token refresh as a shared resource rather than a per-request operation. This approach scales well with application complexity and provides a solid foundation for authentication in production applications.

Remember to adapt the cookie names, error handling, and API endpoints to match your specific application requirements. The core pattern remains the same regardless of the underlying authentication system. Make sure your server is configured to set secure, HttpOnly cookies with appropriate SameSite settings.

For the complete implementation, check out the full code on GitHub Gist.