Solved duplicate OAuth token refresh race condition in Node.js using Promise-based locking
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.
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.

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.
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.

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.