---
title: Token Refresh Races Under Concurrent 401 Responses
description: How to collapse concurrent 401 responses into one token refresh, retry queued requests safely, and settle every waiting Promise when refresh fails.
image: "https://zengliangyi.com/og/blog/en/token-refresh-race-condition.png"
---

**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:



```typescript
// ❌ 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:



```text
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:



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



## Implementing the refresh queue

### 1. Define a subscriber



```typescript
// 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



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



### 3. Coordinate refresh and retry



```typescript
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:



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



### 4. Connect it to Axios



```typescript
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:



```text
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:



```typescript
// ❌ 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:



```typescript
// ✅ 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:



```text
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:



```typescript
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

- [Original TIL issue](https://github.com/ZengLiangYi/til/issues/1)

- [Complete TypeScript implementation](https://github.com/ZengLiangYi/til/blob/main/typescript/token-refresh-queue.ts)

- [RFC 6750: OAuth 2.0 Bearer Token Usage](https://www.rfc-editor.org/rfc/rfc6750)

- [BroadcastChannel API](https://developer.mozilla.org/docs/Web/API/BroadcastChannel)

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.          曾亮懿       October 7, 2026      4 min read

#ChatCrystal

#TypeScript

#Node.js](/en/blog/ai-conversation-parser-boundaries)    [##  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.          曾亮懿       October 7, 2026      4 min read

#ChatCrystal

#Node.js

#TypeScript](/en/blog/ai-sdk-provider-abstraction)    [##  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.          曾亮懿       October 7, 2026      4 min read

#ChatCrystal

#TypeScript

#Node.js](/en/blog/source-adapter-internals)

##  From writing to code

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

[Visit my GitHub](https://github.com/ZengLiangYi)

```json
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "ZengLiangYi Blog",
  "alternateName": [
    "Zeng Liangyi",
    "ZengLiangYi",
    "zengliangyi.com"
  ],
  "url": "https://zengliangyi.com",
  "description": "曾亮懿的个人网站。记录 AI Native 探索、开发者工具与全栈产品实践。"
}
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "Token Refresh Races Under Concurrent 401 Responses",
  "description": "How to collapse concurrent 401 responses into one token refresh, retry queued requests safely, and settle every waiting Promise when refresh fails.",
  "url": "https://zengliangyi.com/en/blog/token-refresh-race-condition/",
  "image": "https://zengliangyi.com/images/blog/token-refresh-race-condition.svg",
  "datePublished": "2026-03-16T00:00:00.000Z",
  "dateModified": "2026-10-08T00:00:00.000Z",
  "author": {
    "@type": "Person",
    "name": "曾亮懿",
    "url": "https://zengliangyi.com/en/about"
  },
  "publisher": {
    "@type": "Organization",
    "name": "ZengLiangYi Blog",
    "logo": {
      "@type": "ImageObject",
      "url": "https://zengliangyi.com/favicon-96x96.png"
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://zengliangyi.com/en/blog/token-refresh-race-condition/"
  }
}
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "Home",
      "item": "https://zengliangyi.com/en"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "Blog",
      "item": "https://zengliangyi.com/en/blog"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "Token Refresh Races Under Concurrent 401 Responses",
      "item": "https://zengliangyi.com/en/blog/token-refresh-race-condition/"
    }
  ]
}
```
