Stop Using Promise.race for Fallbacks: The Case for Promise.any and AggregateError

Table of Contents(11 sections)
Promise.any
Promise.any
AggregateError
AggregateError
Promise.all
Promise.all
Promise.allSettled
Promise.allSettled
Promise.race
Promise.race
Promise.any and AggregateError: The Pattern You Did Not Know You Needed
I ignored Promise.any for months because I thought it was just the opposite of Promise.all - interesting but niche. I was wrong. It is the cleanest solution for a class of real-world problems that previously required awkward workarounds.
The Combinator Landscape
JavaScript gives us four Promise combinators. They work as a family. Understanding them together is faster than learning them in isolation:
| Combinator | Resolves when | Rejects when |
|---|---|---|
Promise.all | Every promise resolves | Any promise rejects |
Promise.allSettled | Every promise settles (always) | Never - always resolves |
Promise.race | Any promise settles | Any promise rejects |
Promise.any | Any promise resolves | Every promise rejects |
Promise.any sits in the empty quadrant: resolves on first success, and aggregates - not short-circuits - failures. That is its whole point.
Mnemonic
any + settled behave like race + all respectively: any = first win, settled = wait for everyone. You can reason about them by combining those two axes instead of memorizing four names.
The Flawed Approach It Replaces
Here is what I used to write whenever I needed "fallback" behavior:
| Old pattern | What you actually needed | |
|---|---|---|
| try A, on fail try B, on fail try C | Resolve on first success - no sequential wait, no wasted bandwidth | Use Promise.any for first success |
| check which fetch resolved, ignore rest | AggregateError gives you exactly that detail for free | You would need to manually inspect which promise settled first |
| `Promise.race` for timeout or fallback | `Promise.any` - first success, honest failure reporting | Race wins on settle, not success - silently eats failures |
The old approach: call the fastest service, use its result if it is good, fall back if not. Promise.race looks tempting but fails silently on race - you do not know whether the winner succeeded or just responded first. Promise.any solves this directly.
Basic Usage
Minimal Example
const urls = [
"https://api.primary.com/data",
"https://api.backup-1.com/data",
"https://api.backup-2.com/data",
];
try {
const response = await Promise.any(
urls.map((url) => fetch(url).then((r) => r.json()))
);
console.log(
"Got data from first successful fetch:",
response
);
} catch (aggregateError) {
// All three failed - see exactly why
console.log(
"All endpoints failed:",
aggregateError.errors
);
aggregateError.errors.forEach((err, i) => {
console.log(`Source ${i}:`, err.message);
});
}
Key detail: when Promise.any rejects, the error is an AggregateError, not a plain Error. It carries an .errors array with one entry per failed input, in the same order as the input iterable.
Use Cases That Are Not Obvious
1. Parallel feature-flag fetches with fallback. You call three flag providers simultaneously. You want the result from whichever one responds first, and only care about failures if every provider is down.
2. Timeout wrapper without race semantics. Wrapping a slow call in a timeout:
async function fetchWithTimeout(url, ms) {
return Promise.any([
fetch(url),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error("Timeout")),
ms
)
),
]);
}
This is cleaner than Promise.race because the timeout only eats the reject, not a resolve.
3. Progressive image loading. Resolve on the first format the browser supports:
const sources = [
fetchImage("webp"),
fetchImage("avif"),
fetchImage("png"), // fallback
];
const image = await Promise.any(sources); // Use whichever format loaded first
4. Resilient auth token refresh. Refresh from two providers simultaneously - use whichever succeeds:
const tokens = await Promise.any([
refreshGitHubToken(),
refreshGitLabToken(),
]); // Use tokens regardless of which provider responded
AggregateError in Depth
The AggregateError object reveals more than a plain rejection:
try {
await Promise.any(failingPromises);
} catch (err) {
// err is an AggregateError
console.log(err.name); // "AggregateError"
console.log(err.message); // "All promises were rejected"
console.log(err.errors); // [TypeError, ReferenceError, RangeError]
...
}
err.errors preserves the individual error objects so you can branch logic per-source. This is what makes Promise.any genuinely useful for logging and error-reporting - not just "first success" semantics.
The Parallelism Lever
Flattening latency by running I/O in parallel is the oldest JavaScript optimization trick. Promise.all covered the "all must succeed" case. Promise.any covers "at least one succeeds", which is most real-world requests.
Compare:
- A single API call that retries on failure is sequential overhead: you wait for timeout on each attempt.
- The same request fired to two services in parallel with
Promise.anyis time bounded by the best of both: the slow failure cost is capped at the faster timeout.
This asymmetry matters most on mobile or high-latency connections where individual timeouts are long.
Compatibility and Caveats
- Requires Node.js 15+, Deno 1.0+, or all modern browsers (Chrome 85+, Firefox 79+, Safari 14+). Not available in Node 14.
- When every input rejects, resulting
AggregateError.messageis always the literal string"All promises were rejected": do not parse it for debugging, use.errorsinstead. - Non-Promise iterable entries are coerced exactly like the other Promise methods (consistent behavior since ES2020).
- Because
Promise.anystops calling.thenonly on resolve, a rejecting input still rejects even after another input has already resolved. The extra rejections produce unhandled-rejection warnings unless caught. - An empty iterable rejects immediately:
Promise.any([])rejects withAggregateErrorwhose.errorsarray is empty. This differs fromPromise.all([])which resolves with[].
Quick Reference
// Basic: first success wins
const winner = await Promise.any([
fetch(urlA),
fetch(urlB),
fetch(urlC),
]);
// Fallback: value from whichever call resolved first
// All fail: catch AggregateError - inspect .errors
// Timeout pattern
async function withTimeout(promise, ms) {
return Promise.any([
promise,
new Promise((_, reject) =>
setTimeout(
() => reject(
new Error(`Timed out after ${ms}ms`)
),
ms
)
),
]);
}
// Typed (TypeScript)
function firstSuccessful<T>(
promises: Array<Promise<T>>
): Promise<T> {
return Promise.any(promises);
}
You Might Also Like
- Building Local-First Web Apps with CRDTs: The Complete Yjs & IndexedDB Architecture
- Building Local-First Web Apps with CRDTs: The Complete Yjs & IndexedDB Architecture
- WebGL and Three.js Performance
- Real-Time Data Streaming with Apache Kafka: A Backend Engineer's Guide
Test Your Knowledge
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

How to Get the Correct Y Position of an Element in ChatGPT UI (2026 Guide)
Stop struggling with window.scrollY returning 0! Learn the secret to finding the real scroll container and calculating accurate coordinates in SPAs instantly.
Read more
Introduction to Test-Driven Development (TDD)
I resisted TDD for years. Then a broken refactor convinced me. Here's what the Red-Green-Refactor cycle actually looks like in practice, and why writing tests first changes more than just your test coverage.
Read more
Handling Forms in Next.js 14: Server Actions vs Client Components
A deep dive into managing forms in Next.js 14 using Server Actions, useFormState, useFormStatus, and Zod for schema validation. Learn to build modern, progressive-enhanced forms without client-side JavaScript fatigue.
Read more