The Hidden Part of Refresh Token Implementation that every developers should know

작성자

카테고리:

← 피드로
DEV Community · Nilesh Kumar · 2026-07-24 개발(SW)

What happens when 5 parallel API calls hit for an expired JWT at the exact same millisecond.

Imagine this: You’ve built a sleek, high-performance React dashboard. The UI is sharp, dark mode is gleaming, components are modularized, and React Query is executing parallel data fetches like a grand symphony.

You brew a cup of coffee, open the app after lunch, hit refresh, and… BAM!

You are immediately booted back to the Login screen. No warnings, no friendly error toasts—just a cold, ruthless redirect.

You check your JWT expiration timer. The access token died 5 seconds ago, but your refresh token is valid for another 14 days. So why on earth did your app decide to kick you out like an uninvited party crasher?

Welcome to the chaotic nightmare of Token Refresh Race Conditions in Axios Interceptors.

In this article, we’ll walk through how parallel React queries can accidentally DDOS your own backend, why standard interceptor tutorials fail in production, how we built a promise-queue lock mechanism to solve it, and the subtle “gotcha” lurking in simple error detail checks that almost broke everything anyway.

1. The Problem: The Dashboard Stampede

When a user logs into our app and opens the main dashboard, React Query triggers a stampede of concurrent API requests:

  1. GET /api/teams/users/ (Fetch team members)
  2. GET /api/teams/addresses/ (Fetch locations)
  3. GET /api/auth/profile/ (Fetch user profile)
  4. GET /api/auth/activity/recent/ (Fetch activity log)
  5. GET /api/notifications/ (Fetch unread alerts)

Under normal circumstances, all five requests ride happily on the same valid Bearer <access_token> HTTP header.

React App ---------------------------------------------> Django Backend
          GET /users/        [Bearer valid]  ---> 200 OK
          GET /locations/    [Bearer valid]  ---> 200 OK
          GET /profile/      [Bearer valid]  ---> 200 OK

Enter fullscreen mode Exit fullscreen mode

The Ticking Time Bomb

Fast forward 15 minutes. The short-lived access token expires.

The user clicks on the “Analytics” tab. All 5 queries trigger at the exact same millisecond (T = 0ms).

Instead of sending one token refresh request, updating the token, and retrying the remaining four… all 5 requests fail with 401 Unauthorized simultaneously.

React App ---------------------------------------------> Django Backend
  (T=0ms) GET /users/        [Bearer EXPIRED] ---> 401 Unauthorized
  (T=0ms) GET /locations/    [Bearer EXPIRED] ---> 401 Unauthorized
  (T=0ms) GET /profile/      [Bearer EXPIRED] ---> 401 Unauthorized
  (T=0ms) GET /activity/     [Bearer EXPIRED] ---> 401 Unauthorized
  (T=0ms) GET /notifications/ [Bearer EXPIRED] ---> 401 Unauthorized

Enter fullscreen mode Exit fullscreen mode

Without synchronization, every single failed request wakes up its own Axios error interceptor and independently calls POST /api/auth/refresh-token/.

  (T=5ms) POST /auth/refresh-token/ (Request #1) ---> Generates Token Set A
  (T=6ms) POST /auth/refresh-token/ (Request #2) ---> Invalidates Token Set A!
  (T=7ms) POST /auth/refresh-token/ (Request #3) ---> Invalidates Token Set B!

Enter fullscreen mode Exit fullscreen mode

😂 Reality Check:
Sending 5 concurrent token refresh calls to a server utilizing single-use refresh token rotation is the frontend equivalent of pulling out 5 credit cards at a vending machine, swiping them all in a fraction of a second, and wondering why the bank locked your account for suspicious activity.

Because modern security backends (like Django REST Framework with SimpleJWT or OAuth2 rotation) invalidate the previous refresh token the instant a new one is issued, Request #2 invalidates the token generated by Request #1.

By the time Request #1 tries to retry its original call with Token Set A, the server responds with: “Token revoked!”

The frontend panics, assumes the user’s session is hijacked, clears localStorage, and nukes the user back to /login.

2. The Investigation: Down the DevTools Rabbit Hole

Here is how the debugging session went down:

⏱️ Timeline of a Frontend Meltdown

  • 11:00 AM — “Everything works fine in local dev!” (Famous last words. Token lifetime was set to 24 hours in local .env).
  • 11:30 AM — Lowered ACCESS_TOKEN_LIFETIME to 1 minute to test session persistence.
  • 11:31 AM — Refreshed the page. Got thrown to /login.
  • 11:35 AMThinking: “Ah, React Query must be invalidating state too quickly. I’ll blame React Query.” (React Query was innocent).
  • 11:45 AM — Opened Chrome DevTools Network Tab. Filtered by /refresh-token/.
  • 11:46 AM — SAW FIVE IDENTICAL POST /api/auth/refresh-token/ REQUESTS FIRING IN PARALLEL AT 11:45:02.104ms.
  • 12:00 PM — Realized we lacked an interceptor lock mechanism.
  • 12:30 PM — Added a basic isRefreshing boolean flag.
  • 01:15 PM — App still logged out users randomly.
  • 02:00 PM — Questioned career choices and analyzed the raw JSON response payload from Django REST Framework.
{
  "detail": "Given token not valid for any token type",
  "code": "token_not_valid",
  "messages": [
    {
      "token_class": "AccessToken",
      "token_type": "access",
      "message": "Token is invalid or expired"
    }
  ]
}

Enter fullscreen mode Exit fullscreen mode

That’s when the lightbulb turned on.

3. The Root Cause: Why Naive Interceptors Fail

Most tutorials on YouTube and Medium give you a response interceptor pattern that looks roughly like this:

// ❌ NAIVE INTERCEPTOR PATTERN (DO NOT USE IN PRODUCTION)
let isRefreshing = false;

axiosInstance.interceptors.response.use(
  (response) => response.data,
  async (error) => {
    if (error.response?.status === 401) {
      if (!isRefreshing) {
        isRefreshing = true;
        const newToken = await refreshToken();
        isRefreshing = false;
        return axiosInstance(error.config);
      }
    }
    return Promise.reject(error);
  }
);

Enter fullscreen mode Exit fullscreen mode

Why this naive solution breaks:

  1. It ignores concurrent requests: If Request #1 sets isRefreshing = true and starts fetching a token, Request #2 through #5 hit if (!isRefreshing) which evaluates to false. They immediately fall through to return Promise.reject(error)! Your UI components receive raw 401 errors and crash.
  2. No Request Queue: Requests #2 through #5 need a way to pause execution until Request #1 finishes refreshing, and then retry with Request #1’s newly minted access token.
  3. The SimpleJWT Error Trapping Bug: In DRF / SimpleJWT, an expired access token returns "Given token not valid for any token type". Many frontend codebases check if error.response?.data?.detail contains "token_not_valid" and immediately call logout(), assuming the session is dead!

⚠️ Gotcha:
If your interceptor checks isTokenExpired by matching string error messages returned from access token failures, it will execute handleLogout() before even attempting to refresh! You literally built a feature that logs users out the exact microsecond their token expires.

4. The Technical Solution: Building a Subscriber Queue Lock

To solve token refresh race conditions elegantly, we need three core primitives in our HTTP client:

  1. A Lock Flag (isRefreshing): A mutex boolean preventing multiple refresh network calls.
  2. A Pending Promise Queue (failedQueue): An array holding the resolve and reject handles of all requests that failed while a refresh was in progress.
  3. A Queue Flusher (processQueue): A helper function that executes all pending promises in order once the new access token arrives.

The Architectural Flow

                      +-----------------------------+
                      | Incoming 401 Unauthorized   |
                      +--------------+--------------+
                                     |
                           Is isRefreshing TRUE?
                                 /       \
                               YES        NO
                              /            \
    +----------------------------+      +-------------------------------+
    | Create new pending Promise |      | Set isRefreshing = TRUE       |
    | Push {resolve,reject} to   |      | Call POST /auth/refresh-token |
    | failedQueue array          |      +---------------+---------------+
    +----------------------------+                      |
                                            Did refresh succeed?
                                                  /      \
                                                YES       NO
                                               /           \
                 +-------------------------------+       +----------------------------+
                 | Save access & refresh tokens  |       | Call processQueue(error)  |
                 | Call processQueue(null, token)|       | Reject all queued promises |
                 | Retry original request        |       | Clear localStorage & Logout|
                 +-------------------------------+       +----------------------------+

Enter fullscreen mode Exit fullscreen mode

5. The Code: Implementation Breakdown

Here is our production-ready implementation inside axiosInstance.js.

Step 1: Initialize the Lock & Queue

// src/httpRequest/axiosInstance.js
import axios from "axios";
import apiRoutes from "./apiRoutes";
import { BASE_URL } from "@/utils/constants";

const axiosInstance = axios.create({
  baseURL: BASE_URL,
  headers: { "Content-Type": "application/json" },
});

// Mutex lock flag and promise queue
let isRefreshing = false;
let failedQueue = [];

// Helper to flush the queue when refresh completes or fails
const processQueue = (error, token = null) => {
  failedQueue.forEach((prom) => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(token);
    }
  });

  failedQueue = [];
};

Enter fullscreen mode Exit fullscreen mode

Step 2: Request Interceptor (Attaching the Token)

axiosInstance.interceptors.request.use(
  (config) => {
    const accessToken = localStorage.getItem("accessToken");
    if (accessToken) {
      config.headers.Authorization = `Bearer ${accessToken}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

Enter fullscreen mode Exit fullscreen mode

Step 3: Response Interceptor (The Magic Queue Engine)

axiosInstance.interceptors.response.use(
  (response) => response.data,
  async (error) => {
    const originalRequest = error.config;

    // Only process 401 errors that haven't already been retried
    if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
      const refreshToken = localStorage.getItem("refreshToken");
      const isRefreshRequest = originalRequest.url?.includes(apiRoutes.refreshToken);

      // 🛑 CRITICAL CHECK:
      // If no refresh token exists, OR if the request that failed with 401 WAS the refresh call itself,
      // then the user's session is genuinely dead. Logout immediately.
      if (!refreshToken || isRefreshRequest) {
        if (!window.isLoggingOut) {
          sessionStorage.setItem(
            "logoutMessage",
            JSON.stringify({
              title: "Session Expired",
              message: "Your session has expired. Please login again.",
            })
          );
        }
        handleLogout().catch(console.error);
        return Promise.reject(error);
      }

      // 🔒 QUEUE LOGIC:
      // If a token refresh is already underway, DO NOT fire another API call.
      // Return a pending promise and store its controls in failedQueue.
      if (isRefreshing) {
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        })
          .then((newToken) => {
            // Update original request authorization header with the new token
            if (newToken) {
              originalRequest.headers.Authorization = `Bearer ${newToken}`;
            }
            return axiosInstance(originalRequest);
          })
          .catch((err) => Promise.reject(err));
      }

      // Mark request as retried and lock the refresh process
      originalRequest._retry = true;
      isRefreshing = true;

      try {
        // Perform a SINGLE token refresh request
        const route = `${BASE_URL}${apiRoutes.refreshToken}`;
        const response = await axios.post(route, { refresh: refreshToken });
        const { access, refresh } = response.data;

        // Persist new tokens
        if (access) localStorage.setItem("accessToken", access);
        if (refresh) localStorage.setItem("refreshToken", refresh);

        // 🔓 UNLOCK QUEUE: Resolve all waiting requests with the new token
        processQueue(null, access);

        // Retry the original request that triggered the 401
        originalRequest.headers.Authorization = `Bearer ${access}`;
        return axiosInstance(originalRequest);
      } catch (refreshError) {
        // If refresh failed, reject all queued promises and wipe session
        processQueue(refreshError, null);

        if (!window.isLoggingOut) {
          sessionStorage.setItem(
            "logoutMessage",
            JSON.stringify({
              title: "Session Expired",
              message: "Your session has expired. Please login again.",
            })
          );
        }
        handleLogout().catch(console.error);
        return Promise.reject(refreshError);
      } finally {
        // Always release lock, even if an unexpected exception occurred
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

Enter fullscreen mode Exit fullscreen mode

6. Before vs. After: The Proof in the Metrics

Let’s compare the behavior of our frontend application before and after implementing the queueing interceptor architecture:

Metric / Behavior Naive Interceptor (Before) Queued Mutex Interceptor (After) Concurrent 401 Requests Fires $N$ parallel POST /auth/refresh-token/ calls Fires 1 single POST /auth/refresh-token/ call Token Rotation Behavior Invalidates tokens mid-flight, causing random logouts Gracefully passes single new access token to all subscribers User Experience Users randomly dumped to /login every 15–30 minutes Imperceptible transition; active UI components auto-retry seamlessly Backend Load DB lock contention on token table during tab switches Minimal, predictable backend auth load Error Handling Safety Leaks unhandled promise rejections on parallel queries Rejects entire queue cleanly if refresh token is revoked

7. Pro-Level Enhancements: What We Can Improve Next

Even with a working promise queue, software architecture is never “finished.” Here are 3 high-value improvements to take this interceptor setup to senior-engineer perfection:

🚀 1. Abstracting Storage Away from localStorage

Currently, our interceptor directly queries localStorage.getItem("accessToken").

  • The Problem: localStorage is vulnerable to XSS attacks and synchronous read bottlenecks.
  • The Upgrade: Move access tokens into memory (React Context / Auth Singleton) and store refresh tokens in HttpOnly, SameSite Cookies. The browser will send the cookie automatically, removing localStorage reads from Javascript entirely.

🚀 2. Adding a Request Timeout Safeguard to the Queue

  • The Problem: If the network hangs during the token refresh request, isRefreshing stays true, and queued promises in failedQueue hang forever (memory leak).
  • The Upgrade: Add an AbortController timeout (e.g., 10 seconds) to the refresh request, auto-rejecting failedQueue if the server fails to respond promptly.
// Timeout Guard Example
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);

try {
  const response = await axios.post(route, { refresh }, { signal: controller.signal });
  clearTimeout(timeoutId);
  // ...
} catch (err) {
  clearTimeout(timeoutId);
  processQueue(err, null);
}

Enter fullscreen mode Exit fullscreen mode

🚀 3. Deduplicating Concurrent Token Requests Across Browser Tabs

  • The Problem: If a user opens 4 browser tabs to your app simultaneously, each tab runs its own JS environment and memory space. isRefreshing in Tab A won’t stop Tab B from firing a refresh call!
  • The Upgrade: Use the BroadcastChannel API or Web Locks API (navigator.locks.request) to coordinate token refresh across multiple browser tabs synchronously!

8. Things I Learned (The Hard Way)

💡 Lesson #1: Never Trust Error Message Strings for Control Flow
Backend error strings like "Given token not valid for any token type" change across library updates. Rely on HTTP status codes (e.g., 401) and route endpoints, not substring matches on error.response.data.detail.

⚠️ Gotcha #2: Don’t Forget finally { isRefreshing = false; }
If your token refresh call throws an unexpected error (like a network offline drop) and isRefreshing isn’t reset in a finally block, your app will lock up permanently until the user manually reloads the browser.

🚀 Improvement #3: Dynamic Header Replacement in Queued Promises
When a queued request resolves via .then((newToken) => ...), make sure you explicitly set originalRequest.headers.Authorization = 'Bearer ' + newToken. Relying on a global interceptor to read stale state might send the old token a second time!

9. Conclusion

Token refresh handling is one of those deceptively simple features. It sounds like a 20-minute job: “Just catch a 401, refresh the token, and try again!”

Three hours and 47 console.log statements later, you discover queue theory, promise resolution closures, and backend DB locks.

By introducing a dedicated Mutex Lock and Subscriber Queue, we turned a chaotic, user-kicking bug into a silent, rock-solid background process.

The feature eventually worked flawlessly. My confidence needed a quick hotfix, but hey—that’s just another Tuesday in frontend engineering.

Got questions about handling auth state in React Query or Axios? Drop a comment below or connect on GitHub!

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다