複数リクエストが同時にトークン更新を走らせる問題をPromiseロックで解決する
はじめに
複数の外部APIと連携するNode.js自動化システムを運用しているなかで、OAuthトークンのリフレッシュが重複して発行される問題に遭遇しました。
症状はこうです。複数の処理が並列で走るタイミングで、「トークンの期限切れ → リフレッシュ」が競合して同時に3〜6件のリフレッシュリクエストが送信されることがありました。外部APIによっては、これが原因でリフレッシュトークンが無効化されてしまい、その後の処理がすべて認証エラーになるケースが発生していました。
この問題は異なるプロジェクトで独立して2回発生しました。1つはMicrosoft Graph API / Box APIとのOAuth連携、もう1つはPlaywrightによるブラウザ認証(トークン更新処理が重いケース)です。この記事では、Promiseを使ったシンプルな解決策を紹介します。
問題: なぜリフレッシュが重複するのか
まず典型的なトークン管理の実装を見てみましょう。
class ApiClient {
private accessToken: string = "";
private expiredAt: number = Date.now();
private async checkToken(): Promise<void> {
if (Date.now() >= this.expiredAt) {
await this.refreshAccessToken(); // ← ここが問題
}
}
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();
// ...
}
}
このコードの問題は、checkToken() が非同期であることです。
// 並列実行されるケース
await Promise.all([
client.getUsers(),
client.getOrders(),
client.getProducts(),
]);
Promise.all で並列実行すると、各メソッドが checkToken() を呼ぶ時点ではトークンはまだ期限切れのままです。なぜなら最初の refreshAccessToken() がまだ完了していないからです。

if (Date.now() >= this.expiredAt) のチェックは同期的に評価されますが、その後の await refreshAccessToken() は非同期なため、3つのメソッドがほぼ同時にチェックを通過して、それぞれがリフレッシュを開始してしまいます。
Playwright経由のブラウザ認証のように refreshToken() が重い処理の場合、重複するとブラウザインスタンスが複数立ち上がり、リソースを大量消費します。
よくある間違った解決策
boolean のフラグで「リフレッシュ中かどうか」を管理しようとするパターンを見かけます。
private isRefreshing = false;
private async checkToken(): Promise<void> {
if (this.isRefreshing) return; // ← これでは解決しない
if (Date.now() >= this.expiredAt) {
this.isRefreshing = true;
await this.refreshAccessToken();
this.isRefreshing = false;
}
}
これは動作しません。確かに isRefreshing = true は await の前に同期的に代入されるため、2番目以降の呼び出し元はフラグを見てリフレッシュの重複自体は防げます。しかし、本当の問題はその先にあります。
isRefreshing が true のとき、後続の呼び出し元は早期 return してしまい、リフレッシュの完了を待たずに処理を進めてしまいます。結果として、まだ期限切れの古いトークンを使ったAPIリクエストが発行され、認証エラーが発生します。
解決策: Promiseそのものをロックとして使う
正しいアプローチは、進行中のリフレッシュ処理のPromiseを保存しておき、後続の呼び出し元がそのPromiseをawaitすることです。
class ApiClient {
private accessToken: string = "";
private expiredAt: number = Date.now();
private refreshToken: string = "";
private tokenRefreshPromise: Promise<void> | null = null; // ← 追加
private async checkToken(): Promise<void> {
// リフレッシュがすでに進行中なら、そのPromiseをawaitして待つ
if (this.tokenRefreshPromise) {
await this.tokenRefreshPromise;
// 待ち終わったあと、トークンが有効になっていれば終了
if (this.refreshToken && Date.now() < this.expiredAt) {
return;
}
}
// リフレッシュが必要な場合
if (!this.refreshToken || Date.now() >= this.expiredAt) {
const refreshOperation = this.refreshAccessToken();
// Promiseを保存して、後続の呼び出し元が共有できるようにする
this.tokenRefreshPromise = refreshOperation;
try {
await refreshOperation;
} finally {
// 完了後(成功・失敗を問わず)Promiseをクリア
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;
}
}
1回の更新で全員がトークンを共有します。

なぜこれが機能するのか
Promiseの状態モデル
JavaScriptのPromiseは状態を持つオブジェクトです。
pending:まだ完了していないfulfilled:成功で完了rejected:失敗で完了
重要なのは、同じPromiseを複数箇所でawaitできることです。1つのPromiseを変数に保存しておけば、何回awaitしても同じ結果を得られます。新しいリクエストが作られるわけではありません。
const promise = fetchSomething(); // 1回だけ実行される
await promise; // 結果を待つ(リクエストA)
await promise; // 同じ結果を待つ(リクエストB)← 新しいfetchは発生しない
finallyブロックの役割
try {
await refreshOperation;
} finally {
this.tokenRefreshPromise = null;
}
finallyでtokenRefreshPromiseをnullに戻すことで、次にトークンが切れたときに新しい更新を開始できるようにしています。try/finallyを使う理由は、更新が失敗した場合でもフラグをリセットするためです。これがないと、更新失敗後に永遠に再試行されなくなります。
二重チェックの意味
if (this.tokenRefreshPromise) {
await this.tokenRefreshPromise;
if (Date.now() < this.expiredAt) return; // ← ここ
}
awaitの後にもう一度有効期限をチェックしています。これは、待っている間に更新が失敗した場合の防御です。成功していればトークンは有効なのでreturn。失敗していれば次のifブロックで自分が更新を試みます。
「mutexでいいのでは?」
Node.jsはシングルスレッドなので、Javaのようなsynchronizedブロックは不要です。しかし、async/awaitによる論理的な並行性は存在します。awaitのたびにイベントループに制御が戻り、他のコールバックが実行される可能性があります。
今回のPromise共有パターンは、Node.jsの並行モデルに最も自然にフィットする軽量な排他制御です。
実際の運用での気づき
このパターンを実装するきっかけになったのは、異なるAPIクライアントで独立して同じ問題が発生したことです。Microsoft Graph API用のクライアントとBox API用のクライアントの両方で、並列実行時に同様の競合が起きていました。
それぞれのOAuth実装はAPIの仕様が異なる(スコープ、エンドポイント、レスポンス形式など)ものの、トークン管理のロジックはほぼ同一でした。2つ目の問題が起きたとき、「これは構造的な問題だ」と気づき、同じ解決策を適用しました。
初期化が必要なケース(refreshTokenがない状態)にも対応が必要だった点も実装で重要でした。初回は refreshToken が存在しないため、ブラウザ経由でOAuth認可コードを取得するフローが走ります。このフローも同じ tokenRefreshPromise でロックすることで、並列実行時に複数のブラウザが立ち上がる問題を防いでいます。
// refreshTokenの有無で処理を分岐しながら、同じロックで保護する
if (!this.refreshToken || Date.now() >= this.expiredAt) {
let refreshOperation: Promise<void>;
if (!this.refreshToken) {
refreshOperation = this.forgeRefreshToken(); // ブラウザでOAuth認可
} else {
refreshOperation = this.refreshAccessToken(); // 通常のリフレッシュ
}
this.tokenRefreshPromise = refreshOperation;
try {
await refreshOperation;
} finally {
this.tokenRefreshPromise = null;
}
}
テストでの確認方法
このパターンが正しく動くことを確認するには、複数のリクエストを同時に投げます。
// refreshTokenが1回だけ呼ばれることを確認
const results = await Promise.all([
client.getUsers(),
client.getOrders(),
client.getProducts(),
]);
// refreshTokenのログが1回だけ出力されていることを確認
トークン更新の先にあるもの
このパターンはトークン更新に限らず、「高コストな操作を重複させたくない」場面で広く使えます。
- キャッシュの再構築
- 外部APIへの接続初期化
- 設定ファイルの再読み込み
まとめ
| 方式 | 挙動 | 問題 |
|---|---|---|
| 素朴なチェック | 全員がrefreshを実行 | リソース浪費、レースコンディション |
| boolean フラグ | 重複防止するが待機しない | 古いトークンで認証エラー |
| Promise共有 | 最初の1人だけ実行、他は待機 | なし |
| mutex/ロック | 排他制御 | Node.jsでは過剰 |
一度この問題にハマると、以降はAPIクライアントを書くたびに「同時更新は大丈夫か?」と考えるようになります。コードとしては数行の違いですが、本番環境での安定性に大きく影響するパターンです。





