Promise.race does not return "the first successful result" — an async bug I hit while doing recursive directory search

Promise.race does not return "the first successful result" — an async bug I hit while doing recursive directory search

I encountered a bug where using Promise.race in recursive directory search caused empty folders to immediately return null, ignoring the actual search results. I will explain the Promise.race specification and how to fix it using Promise.all and find.
2026.07.31

This page has been translated by machine translation. View original

Introduction

I had a function that recursively searches directories, using Promise.race to parallelize subfolder exploration. The intent was "just return the first result found," but there was a bug where in reality, only "the result of the first completed Promise" is returned, which would resolve without issue even if that result was null (not found).

This article introduces the cause of this bug and how to fix it.

The Buggy Code

Below is a function that recursively searches for a folder with a specified name.

file-util.ts
// Buggy version
public async findTargetFolderAsync(
  startFolder: string,
  targetFolder: string
): Promise<string | null> {
  const items = await fs.readdir(startFolder, { withFileTypes: true });

  // First check if the target exists directly underneath
  for (const item of items) {
    if (item.isDirectory() && item.name === targetFolder) {
      return path.join(startFolder, item.name);
    }
  }

  // If not, search subfolders in parallel
  const subfolderPromises = items
    .filter((item) => item.isDirectory())
    .map((item) => {
      const itemPath = path.join(startFolder, item.name);
      return this.findTargetFolderAsync(itemPath, targetFolder);
    });

  // This is the problem
  return Promise.race(subfolderPromises);
}

What Happens

Consider the following directory structure.

root/
  ├── empty-folder/        (no contents → returns null immediately)
  ├── docs/                (no contents → returns null immediately)
  └── deep/
      └── nested/
          └── target/      (← this is what we're looking for)

Promise.race returns the result of "the first settled Promise."

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

  1. Searching empty-folder → no contents, so resolves with null immediately
  2. Searching docs → same, resolves with null immediately
  3. Searching deep → takes time until nested/target/ is found

Promise.race returns null because 1 completes first. The Promise that finds deep/nested/target/ is still pending, but race has already finalized its result.

Revisiting the Promise.race Specification

Looking at the MDN documentation makes it clear.

Promise.race() is a static method that takes an iterable of promises as input and returns a single Promise. The returned promise settles with the eventual state of the first promise that settles.

In other words, Promise.race means the following.

  • "Returns the result of the first completed one" → correct
  • "Returns the result of the first successful one" → wrong

Resolving with null is also a "success." race does not look at the value's contents.

Fixed Code

Wait for all searches with Promise.all, then extract the first non-null from the results.

file-util.ts
// Fixed version
public async findTargetFolderAsync(
  startFolder: string,
  targetFolder: string
): Promise<string | null> {
  try {
    const items = await fs.readdir(startFolder, { withFileTypes: true });

    // First check if the target exists directly underneath
    for (const item of items) {
      if (item.isDirectory() && item.name === targetFolder) {
        return path.join(startFolder, item.name);
      }
    }

    // Search subfolders in parallel
    const subfolderPromises = items
      .filter((item) => item.isDirectory())
      .map((item) => {
        const itemPath = path.join(startFolder, item.name);
        return this.findTargetFolderAsync(itemPath, targetFolder);
      });

    // Wait for all searches to complete, then return the first non-null result
    const results = await Promise.all(subfolderPromises);
    return results.find((result) => result !== null) ?? null;
  } catch (err) {
    console.error(`Error reading folder: ${startFolder}`, err);
    return null;
  }
}

Only two lines changed.

// Before (bug)
return Promise.race(subfolderPromises);

// After (fix)
const results = await Promise.all(subfolderPromises);
return results.find((result) => result !== null) ?? null;

Why It's Tricky

Works correctly with shallow directory structures

When the target folder is at the first level, it's found by the direct for loop and never reaches Promise.race. Because the folder structure in the test environment was simple, it worked correctly during development.

Failures are non-deterministic

The result of Promise.race depends on the timing of the event loop. Depending on disk I/O speed and OS file cache state, the correct result might occasionally be returned. Bugs that "sometimes don't work" are hard to reproduce and take longer to discover.

Returns null instead of an error

Since no exception is thrown, if the caller writes if (!result), it will be processed as "not found." No error appears in the logs either.

Cases Where Promise.race Can Be Used Correctly

There are also situations where Promise.race is appropriate.

Implementing timeouts

const result = await Promise.race([
  fetchData(),
  new Promise((_, reject) =>
    setTimeout(() => reject(new Error("Timeout")), 5000)
  ),
]);

In this case, "the first one to complete" is exactly the semantics we want.

Selecting the fastest response

// Use whichever CDN responds first
const data = await Promise.race([
  fetchFromCDN1(url),
  fetchFromCDN2(url),
  fetchFromCDN3(url),
]);

When all responses return the same value, using the fastest one is correct.

Promise.any as an Alternative

Promise.any, added in ES2021, returns the result of the first fulfilled Promise. Rejections are ignored.

// Promise.any returns the first "success"
const result = await Promise.any(subfolderPromises);

However, in our case, resolving with null is also fulfilled, so the bug is not fixed with Promise.any as-is. A "Promise resolved with null" is not rejected.

If "not found" is rejected, Promise.any can be used

Changing the approach so that "not found" rejects instead of resolving with null allows Promise.any to work correctly.

// Promise.any + reject version
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) {
    // No subfolders = not found in this branch
    // Reject to inform the parent Promise.any of "absence"
    throw new Error(`Not found in ${startFolder}`);
  }

  // Ignore rejected ones and return the first found result
  return Promise.any(subfolderPromises);
}

On the caller side, catch AggregateError (all branches rejected = truly not found).

try {
  const result = await findTargetFolderAsync(root, "target");
} catch (e) {
  // AggregateError = not found in any search path
  return null;
}

Comparison of Promise.all vs Promise.any

Promise.all + .find() Promise.any + reject
Completion timing Waits for all branches to complete Returns as soon as the first one is found
Performance Waits even if deep branches remain Returns immediately once found
Error handling Simple (null check only) Requires catching AggregateError
Return type string | null string (not found is an exception)

If performance is a priority, the Promise.any + reject version is superior. Especially in cases where a directory tree is deep and one branch finds a result early, results can be returned without waiting for the remaining searches. On the other hand, if simplicity is a priority, Promise.all + .find() is easier to handle.

Summary

  • Promise.race returns "the result of the first completed one," regardless of whether the value is null
  • When you want "the first found result" in a recursive search, use Promise.all + .find()
  • If "not found" is designed to reject, Promise.any is also effective and advantageous in terms of performance
  • This bug is hard to reproduce and tends to be discovered late in shallow directory structures or fast I/O environments
  • Appropriate use cases for Promise.race are timeout implementations and fastest response selection

Share this article