Promise.raceは「最初に成功した結果」を返さない — 再帰的ディレクトリ検索で踏んだ非同期バグ

Promise.raceは「最初に成功した結果」を返さない — 再帰的ディレクトリ検索で踏んだ非同期バグ

再帰的ディレクトリ検索で Promise.race を使ったところ、空フォルダが即座に null を返して本来の検索結果が無視されるバグに遭遇しました。Promise.race の仕様と、Promise.all + find による修正方法を解説します。
2026.07.31

はじめに

再帰的にディレクトリを検索する関数で、サブフォルダの探索を並列化するために Promise.race を使っていました。「最初に見つかった結果を返せばいい」という意図でしたが、実際には「最初に完了した Promise の結果」が返るだけで、それが null(見つからなかった)であっても構わず resolve してしまうバグがありました。

本記事では、このバグの原因と修正方法を紹介します。

バグのあったコード

以下は、指定した名前のフォルダを再帰的に探索する関数です。

file-util.ts
// バグあり版
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」の結果を返します。

promise-race-vs-promise-all-async-bug-race-timing

  1. empty-folder の探索 → 中身がないので即座に null で resolve
  2. docs の探索 → 同じく即座に null で resolve
  3. deep の探索 → nested/target/ を見つけるまで時間がかかる

Promise.race1 が最初に完了するため null を返しますdeep/nested/target/ が見つかる Promise はまだ pending ですが、race はもう結果を確定してしまっています。

Promise.race の仕様を再確認

MDNのドキュメントを見ると明確です。

Promise.race() は静的メソッドで、入力としてプロミスの反復可能オブジェクトを受け取り、単一の Promise を返します。この返されたプロミスは、最初に決定したプロミスの最終的な状態で決定されます

つまり Promise.race は以下の意味です。

  • 「最初に完了した結果を返す」 → 正しい
  • 「最初に成功した結果を返す」 → 間違い

null で resolve することも「成功」です。race は値の中身を見ません。

修正後のコード

Promise.all で全探索を待ち、結果から最初の非nullを取り出します。

file-util.ts
// 修正版
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 の適切な用途はタイムアウト実装や最速レスポンス選択

この記事をシェアする

関連記事