Skip to content
曾亮懿 ZengLiangYi Blog

Token Refresh Races Under Concurrent 401 Responses

How to collapse concurrent 401 responses into one token refresh, retry queued requests safely, and settle every waiting Promise when refresh fails.

曾亮懿

曾亮懿

4 min read
Table of contents Show Hide

TL;DR: When several requests receive 401 responses at the same time, a naive interceptor starts several token refreshes and creates a race. An isRefreshing flag plus a subscriber queue collapses them into one refresh. The failure path must also notify every subscriber, or their returned Promises remain pending forever.

This article assumes familiarity with async/await, HTTP interceptors such as Axios or Fetch wrappers, and the basics of JWT authentication.

The problem: a 401 rarely arrives alone

A first token-refresh interceptor often looks like this:

// ❌ Naive implementation
axios.interceptors.response.use(null, async (error) => {
  if (error.response?.status === 401) {
    const newToken = await refreshToken();
    error.config.headers.Authorization = `Bearer ${newToken}`;
    return axios(error.config);
  }
  return Promise.reject(error);
});

It works when one request expires. Real pages, however, routinely request the current user, notification counts, and business data in parallel. If the access token expires while those requests are in flight, every 401 starts a refresh:

Request A → 401 → refreshToken() ─┐
Request B → 401 → refreshToken()  │
Request C → 401 → refreshToken()  │← several refreshes at once
Request D → 401 → refreshToken() ─┘

With refresh-token rotation, every successful refresh invalidates the previous refresh token. The first request may succeed while the rest continue with an obsolete token, fail, and eventually log the user out.

A useful mental model: one checkout line

Think of the requests as customers at a checkout:

  • In the naive version, every customer separately calls the manager.
  • In the queued version, the first customer calls the manager while everyone else waits. When the manager returns with a new token, the whole line can continue.

The model needs one state flag and one queue:

let isRefreshing = false;
let subscribers: Subscriber[] = [];

Implementing the refresh queue

1. Define a subscriber

// A string means success; null means the refresh failed.
type Subscriber = (newToken: string | null) => void;

let isRefreshing = false;
let subscribers: Subscriber[] = [];

The string | null union matters. Subscribers need a failure result as well as a success result.

2. Manage the queue

function addSubscriber(callback: Subscriber) {
  subscribers.push(callback);
}

function notifySubscribers(newToken: string | null) {
  subscribers.forEach((callback) => callback(newToken));
  subscribers = [];
}

3. Coordinate refresh and retry

export async function handleUnauthorized<T>(
  doRefresh: () => Promise<string | null>,
  doRetry: (newToken: string) => Promise<T>,
  onFailure: () => void
): Promise<T> {
  // A refresh is already running: wait for the same result.
  if (isRefreshing) {
    return new Promise<T>((resolve, reject) => {
      addSubscriber((newToken) => {
        if (newToken) {
          doRetry(newToken).then(resolve).catch(reject);
        } else {
          reject(new Error('Token refresh failed'));
        }
      });
    });
  }

  isRefreshing = true;

  // Contract: doRefresh catches request errors and returns null on failure.
  const newToken = await doRefresh();

  if (newToken) {
    notifySubscribers(newToken);
    isRefreshing = false;
    return doRetry(newToken);
  }

  notifySubscribers(null);
  isRefreshing = false;
  onFailure();
  return Promise.reject(new Error('Token refresh failed'));
}

doRefresh should normalize request failures to null. If it can throw directly, isRefreshing will not reset and the waiting queue will never receive a result:

const doRefresh = async () => {
  try {
    return await fetchNewToken();
  } catch {
    return null;
  }
};

4. Connect it to Axios

axios.interceptors.response.use(null, (error) => {
  const { response, config } = error;

  if (response?.status !== 401) return Promise.reject(error);
  if (config?.url?.includes('/auth/login')) return Promise.reject(error);

  // A failed refresh request must not enter the refresh flow again.
  if (config?.url?.includes('/auth/refresh')) {
    clearStorage();
    window.location.href = '/login';
    return Promise.reject(error);
  }

  return handleUnauthorized(
    () => doRefresh(),
    (newToken) => {
      config.headers.Authorization = `Bearer ${newToken}`;
      return axios(config);
    },
    () => {
      clearStorage();
      window.location.href = '/login';
    }
  );
});

The same burst of 401 responses now produces one refresh:

Request A → 401 → isRefreshing=false → start refresh
Request B → 401 → isRefreshing=true  → join queue
Request C → 401 → isRefreshing=true  → join queue
Request D → 401 → isRefreshing=true  → join queue

Refresh succeeds → notifySubscribers(newToken) → retry B, C, and D

The hidden bug: Promises that stay pending

Many implementations clear the queue directly when refresh fails:

// ❌ The queued Promises never receive a result.
isRefreshing = false;
subscribers = [];
onFailure();

The queue contains completion callbacks for the returned Promises. Discarding those callbacks leaves the caller’s Promise neither resolved nor rejected.

A pending Promise is not automatically a permanent memory leak; garbage collection depends on whether anything still references it. The application lifecycle is still broken, though: loading state cannot finish, aggregate Promises cannot settle, and callers may continue retaining closures or other resources.

Broadcast failure to the queue instead:

// ✅ Every waiting request reaches its rejection path.
notifySubscribers(null);
isRefreshing = false;
onFailure();

That is why Subscriber accepts string | null instead of only string.

Edge cases to handle

A 401 from the refresh endpoint

The refresh request itself must bypass handleUnauthorized() or it creates an infinite loop:

refreshToken() → 401 → handleUnauthorized() → refreshToken() → ...

Exclude login and refresh endpoints at the interceptor boundary.

Multiple tabs

isRefreshing is module state inside one JavaScript context. It does not coordinate tabs or iframes that share credentials. Use BroadcastChannel or a SharedWorker when refresh results need to cross those boundaries.

Reset ordering

This implementation notifies the current queue before resetting isRefreshing. Notification therefore remains part of the active refresh cycle instead of opening another cycle while the current queue is still being distributed.

Refresh timeouts

The queue does not provide a timeout. Normalize a slow refresh to null inside doRefresh when your application needs a deadline:

const doRefresh = () =>
  Promise.race([
    fetchNewToken(),
    new Promise<null>((resolve) => {
      setTimeout(() => resolve(null), 10_000);
    }),
  ]);

Trade-offs

Benefits Limitations
No extra dependency and no coupling to one HTTP client Module state does not cross tabs
At most one refresh at a time Error and timeout contracts must be explicit
Both success and failure settle every waiting request Retried requests still need a loop guard

The important part is not the queue by itself. It is giving one refresh attempt a complete lifecycle: success wakes every request, and failure ends every request too.

Source and further reading

Share:

Related Posts

Five AI conversation parsers: retained fields and loss boundaries

Build an acceptance matrix from Claude Code, Codex, Cursor, Trae, and Copilot parsers, checking tool flags, thinking, order, and snapshot coverage.

曾亮懿 曾亮懿
4 min read
#ChatCrystal #TypeScript #Node.js

AI SDK Provider Abstraction: Capabilities and Protocols

Test 7 ChatCrystal providers and AI SDK factories with offline mocks for Responses, Chat Completions, embeddings, and structured output rejection.

曾亮懿 曾亮懿
4 min read
#ChatCrystal #Node.js #TypeScript

SourceAdapter internals: identity, content hashes, and reimport

Trace ChatCrystal’s registry, importer, and remote payload to verify identity, canonical hashes, metadata shortcuts, and parser-version boundaries.

曾亮懿 曾亮懿
4 min read
#ChatCrystal #TypeScript #Node.js

From writing to code

Explore ChatCrystal, developer tools and daily learning notes on my GitHub. Questions about the implementation are welcome.