Promise.raceは「最初に成功した結果」を返さない — 再帰的ディレクトリ検索で踏んだ非同期バグ
はじめに
再帰的にディレクトリを検索する関数で、サブフォルダの探索を並列化するために Promise.race を使っていました。「最初に見つかった結果を返せばいい」という意図でしたが、実際には「最初に完了した Promise の結果」が返るだけで、それが null(見つからなかった)であっても構わず resolve してしまうバグがありました。
本記事では、このバグの原因と修正方法を紹介します。
バグのあったコード
以下は、指定した名前のフォルダを再帰的に探索する関数です。
// バグあり版
public async findTargetFolderAsync(
startFolder: string,
targetFolder: string
): Promise<string | null> {
const items = await fs.readdir(startFolder, { withFileTypes: true });
// まず直下にターゲットがあるか確認
for (const item of items) {
if (item.isDirectory() && item.name === targetFolder) {
return path.join(startFolder, item.name);
}
}
// なければサブフォルダを並列に探索
const subfolderPromises = items
.filter((item) => item.isDirectory())
.map((item) => {
const itemPath = path.join(startFolder, item.name);
return this.findTargetFolderAsync(itemPath, targetFolder);
});
// ここが問題
return Promise.race(subfolderPromises);
}
何が起きるか
以下のようなディレクトリ構造を考えます。
root/
├── empty-folder/ (中身なし → 即座に null を返す)
├── docs/ (中身なし → 即座に null を返す)
└── deep/
└── nested/
└── target/ (← これを探している)
Promise.race は「最初に settle した Promise」の結果を返します。

empty-folderの探索 → 中身がないので即座にnullで resolvedocsの探索 → 同じく即座にnullで resolvedeepの探索 →nested/target/を見つけるまで時間がかかる
Promise.race は 1 が最初に完了するため null を返します。deep/nested/target/ が見つかる Promise はまだ pending ですが、race はもう結果を確定してしまっています。
Promise.race の仕様を再確認
MDNのドキュメントを見ると明確です。
Promise.race()は静的メソッドで、入力としてプロミスの反復可能オブジェクトを受け取り、単一のPromiseを返します。この返されたプロミスは、最初に決定したプロミスの最終的な状態で決定されます。
つまり Promise.race は以下の意味です。
- 「最初に完了した結果を返す」 → 正しい
- 「最初に成功した結果を返す」 → 間違い
null で resolve することも「成功」です。race は値の中身を見ません。
修正後のコード
Promise.all で全探索を待ち、結果から最初の非nullを取り出します。
// 修正版
public async findTargetFolderAsync(
startFolder: string,
targetFolder: string
): Promise<string | null> {
try {
const items = await fs.readdir(startFolder, { withFileTypes: true });
// まず直下にターゲットがあるか確認
for (const item of items) {
if (item.isDirectory() && item.name === targetFolder) {
return path.join(startFolder, item.name);
}
}
// サブフォルダを並列に探索
const subfolderPromises = items
.filter((item) => item.isDirectory())
.map((item) => {
const itemPath = path.join(startFolder, item.name);
return this.findTargetFolderAsync(itemPath, targetFolder);
});
// 全探索の完了を待ち、最初の非null結果を返す
const results = await Promise.all(subfolderPromises);
return results.find((result) => result !== null) ?? null;
} catch (err) {
console.error(`Error reading folder: ${startFolder}`, err);
return null;
}
}
変更点は2行だけです。
// Before (バグ)
return Promise.race(subfolderPromises);
// After (修正)
const results = await Promise.all(subfolderPromises);
return results.find((result) => result !== null) ?? null;
なぜ厄介なのか
浅いディレクトリ構造では正しく動く
ターゲットフォルダが1階層目にある場合、直下の for ループで見つかるため Promise.race まで到達しません。テスト環境のフォルダ構造が単純だったため、開発中は正常に動いていました。
失敗が非決定的
Promise.race の結果はイベントループのタイミングに依存します。ディスクI/Oの速度やOSのファイルキャッシュ状態によって、たまたま正しい結果が返ることもあります。「たまに動かない」バグは再現が難しく、発見が遅れます。
エラーではなく null が返る
例外がスローされるわけではないため、呼び出し側で if (!result) と書いていると「見つからなかった」として処理されます。ログにもエラーは出ません。
Promise.race を正しく使えるケース
Promise.race が適切な場面もあります。
タイムアウトの実装
const result = await Promise.race([
fetchData(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), 5000)
),
]);
この場合、「最初に完了したもの」がまさに欲しいセマンティクスです。
最速レスポンスの選択
// 複数のCDNから最初にレスポンスが来たものを使う
const data = await Promise.race([
fetchFromCDN1(url),
fetchFromCDN2(url),
fetchFromCDN3(url),
]);
全レスポンスが同じ値を返す場合、最速のものを使うのは正しいです。
Promise.any という選択肢
ES2021で追加された Promise.any は、最初に fulfilled になった Promise の結果を返します。rejected は無視します。
// Promise.any は最初の「成功」を返す
const result = await Promise.any(subfolderPromises);
ただし、今回のケースでは null で resolve することも fulfilled なので、そのままでは Promise.any でもバグは解消されません。「nullで resolve された Promise」はrejected ではないためです。
「見つからない」を reject にすれば Promise.any が使える
発想を変えて、「見つからなかった」を null で resolve するのではなく reject する設計にすれば、Promise.any が正しく機能します。
// Promise.any + reject 版
public async findTargetFolderAsync(
startFolder: string,
targetFolder: string
): Promise<string> {
const items = await fs.readdir(startFolder, { withFileTypes: true });
for (const item of items) {
if (item.isDirectory() && item.name === targetFolder) {
return path.join(startFolder, item.name);
}
}
const subfolderPromises = items
.filter((item) => item.isDirectory())
.map((item) =>
this.findTargetFolderAsync(path.join(startFolder, item.name), targetFolder)
);
if (subfolderPromises.length === 0) {
// サブフォルダがない = この枝では見つからなかった
// reject して親の Promise.any に「不在」を伝える
throw new Error(`Not found in ${startFolder}`);
}
// reject されたものは無視し、最初に見つかった結果を返す
return Promise.any(subfolderPromises);
}
呼び出し側では AggregateError(全ブランチが reject した = 本当に見つからなかった)をキャッチします。
try {
const result = await findTargetFolderAsync(root, "target");
} catch (e) {
// AggregateError = 全探索パスで見つからなかった
return null;
}
Promise.all vs Promise.any の比較
Promise.all + .find() |
Promise.any + reject |
|
|---|---|---|
| 完了タイミング | 全ブランチの完了を待つ | 最初に見つかった時点で返る |
| パフォーマンス | 深いブランチが残っていても待つ | 見つかれば即座に返る |
| エラーハンドリング | シンプル(null チェックのみ) |
AggregateError のキャッチが必要 |
| 戻り値の型 | string | null |
string(not found は例外) |
パフォーマンスを重視するなら Promise.any + reject 版が優れています。特にディレクトリツリーが深く、一方のブランチで早期に見つかるケースでは、残りの探索を待たずに結果を返せます。一方、シンプルさを重視するなら Promise.all + .find() が扱いやすいです。
まとめ
Promise.raceは「最初に完了した結果」を返す。値がnullでもお構いなし- 再帰検索で「最初に見つかった結果」が欲しい場合は
Promise.all+.find()を使う - 「見つからない」を reject にする設計なら
Promise.anyも有効。パフォーマンス面で有利 - このバグは浅いディレクトリ構造や高速なI/O環境では再現しにくく、発見が遅れがち
Promise.raceの適切な用途はタイムアウト実装や最速レスポンス選択


