Solved duplicate OAuth token refresh race condition in Node.js using Promise-based locking

Solved duplicate OAuth token refresh race condition in Node.js using Promise-based locking

I solved the problem of duplicate OAuth token refreshes when running parallel with Promise.all by using a pattern that holds an in-progress Promise as a lock. I will explain the pitfalls of boolean flags and the correct implementation method.
2026.07.23

This page has been translated by machine translation. View original

Introduction

While operating a Node.js automation system that integrates with multiple external APIs, I encountered a problem where OAuth token refreshes were being issued in duplicate.

The symptoms were as follows: when multiple processes ran in parallel, "token expiration → refresh" would race, causing 3 to 4 refresh requests to be sent simultaneously. Depending on the external API, this caused the refresh token to be invalidated, resulting in all subsequent processes failing with authentication errors.

This article explains the cause of the problem and introduces a simple solution using Promises.

The Problem: Why Do Duplicate Refreshes Occur?

Let's first look at a typical token management implementation.

ApiClient.ts
class ApiClient {
  private accessToken: string = "";
  private expiredAt: number = Date.now();

  private async checkToken(): Promise<void> {
    if (Date.now() >= this.expiredAt) {
      await this.refreshAccessToken(); // ← This is the problem
    }
  }

  private async refreshAccessToken(): Promise<void> {
    const res = await fetch("/oauth/token", {
      method: "POST",
      body: new URLSearchParams({ grant_type: "refresh_token", ... }),
    });
    const data = await res.json();
    this.accessToken = data.access_token;
    this.expiredAt = Date.now() + data.expires_in * 1000;
  }

  public async getUsers(): Promise<User[]> {
    await this.checkToken();
    // ...
  }

  public async getOrders(): Promise<Order[]> {
    await this.checkToken();
    // ...
  }
}

The problem with this code is that checkToken() is asynchronous.

// Case where parallel execution occurs
await Promise.all([
  client.getUsers(),
  client.getOrders(),
  client.getProducts(),
]);

When executed in parallel with Promise.all, the token is still expired at the point where each method calls checkToken(). This is because the first refreshAccessToken() has not yet completed.

oauth-token-refresh-race-condition-race

The if (Date.now() >= this.expiredAt) check is evaluated synchronously, but the subsequent await refreshAccessToken() is asynchronous, so all three methods pass the check almost simultaneously and each starts its own refresh.

A Common Incorrect Solution

A common pattern is to manage "whether a refresh is in progress" using a boolean flag.

private isRefreshing = false;

private async checkToken(): Promise<void> {
  if (this.isRefreshing) return; // ← This doesn't solve the problem
  if (Date.now() >= this.expiredAt) {
    this.isRefreshing = true;
    await this.refreshAccessToken();
    this.isRefreshing = false;
  }
}

This does not work. While it's true that isRefreshing = true is synchronously assigned before await, so subsequent callers can see the flag and prevent duplicate refreshes, the real problem lies beyond that.

When isRefreshing is true, subsequent callers return early and proceed without waiting for the refresh to complete. As a result, API requests are issued using the old, expired token, causing authentication errors.

The Solution: Using a Promise Itself as a Lock

The correct approach is to save the Promise of the ongoing refresh operation so that subsequent callers can await that same Promise.

ApiClient.ts
class ApiClient {
  private accessToken: string = "";
  private expiredAt: number = Date.now();
  private refreshToken: string = "";
  private tokenRefreshPromise: Promise<void> | null = null; // ← Added

  private async checkToken(): Promise<void> {
    // If a refresh is already in progress, await that Promise and wait
    if (this.tokenRefreshPromise) {
      await this.tokenRefreshPromise;
      // After waiting, if the token is now valid, we're done
      if (this.refreshToken && Date.now() < this.expiredAt) {
        return;
      }
    }

    // If a refresh is needed
    if (!this.refreshToken || Date.now() >= this.expiredAt) {
      const refreshOperation = this.refreshAccessToken();

      // Save the Promise so subsequent callers can share it
      this.tokenRefreshPromise = refreshOperation;

      try {
        await refreshOperation;
      } finally {
        // Clear the Promise after completion (whether success or failure)
        this.tokenRefreshPromise = null;
      }
    }
  }

  private async refreshAccessToken(): Promise<void> {
    const res = await fetch("/oauth/token", {
      method: "POST",
      body: new URLSearchParams({
        grant_type: "refresh_token",
        refresh_token: this.refreshToken,
      }),
    });
    const data = await res.json();
    this.accessToken = data.access_token;
    this.refreshToken = data.refresh_token;
    this.expiredAt = Date.now() + data.expires_in * 1000;
  }
}

Why This Works

A Promise object holds a "reference to the operation" rather than its "result." Even if the same Promise is awaited in multiple places, the refresh request is only issued once.

oauth-token-refresh-race-condition-solution

It's also important that tokenRefreshPromise = null is cleared in the finally block. This ensures that if an error occurs and the refresh fails, a retry is possible on the next call.

Observations from Real-World Operation

What prompted me to implement this pattern was that the same problem occurred independently in different API clients. Both the Microsoft Graph API client and the Box API client experienced similar race conditions during parallel execution.

While each OAuth implementation differed in API specifications (scopes, endpoints, response formats, etc.), the token management logic was nearly identical. When the second issue arose, I realized "this is a structural problem" and applied the same solution.

Handling cases that require initialization (when there is no refreshToken) was also an important consideration during implementation. On the first run, since no refreshToken exists, a flow to obtain an OAuth authorization code via a browser is triggered. By locking this flow with the same tokenRefreshPromise, we prevent the problem of multiple browser windows opening during parallel execution.

// Branch based on whether refreshToken exists, while protecting both with the same lock
if (!this.refreshToken || Date.now() >= this.expiredAt) {
  let refreshOperation: Promise<void>;
  if (!this.refreshToken) {
    refreshOperation = this.forgeRefreshToken(); // OAuth authorization via browser
  } else {
    refreshOperation = this.refreshAccessToken(); // Normal refresh
  }

  this.tokenRefreshPromise = refreshOperation;
  try {
    await refreshOperation;
  } finally {
    this.tokenRefreshPromise = null;
  }
}

Summary

Here is a summary of the key points of the pattern using Promise<void> | null as a lock.

Comparison Item boolean Flag Promise Lock
Handling of subsequent calls Ignored with early return Waits until completion
Duplicate refresh prevention Prevents duplicates but cannot wait Prevents duplicates and can wait
Cleanup on failure Requires manual management Reliably cleared with finally
Code complexity Simple but incomplete Slightly more complex but correct

When you want to implement a "lock" for asynchronous processing, the concept of holding the in-progress Promise itself as state rather than a boolean flag is effective. I believe this is a particularly useful pattern in systems with many external API integrations or scenarios that make heavy use of parallel processing.

Share this article