Promise.race does not return "the first successful result" — an async bug I hit while doing recursive directory search
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.
// 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."

- Searching
empty-folder→ no contents, so resolves withnullimmediately - Searching
docs→ same, resolves withnullimmediately - Searching
deep→ takes time untilnested/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 singlePromise. 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.
// 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.racereturns "the result of the first completed one," regardless of whether the value isnull- When you want "the first found result" in a recursive search, use
Promise.all+.find() - If "not found" is designed to reject,
Promise.anyis 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.raceare timeout implementations and fastest response selection