•9 min read

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

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

Promise.any

Click to reveal
Promise Combinators
Static method that takes an iterable of Promises and returns a new Promise that resolves as soon as ANY input resolves, rejecting only if every input rejects.

Promise.any

Promise Combinators

AggregateError

Click to reveal
Promise Combinators
A new Error subclass introduced in ES2021 that bundles multiple individual errors into a single errors array, used internally by Promise.any.

AggregateError

Promise Combinators

Promise.all

Click to reveal
Promise Combinators
Static method that takes an iterable of Promises and returns a new Promise that resolves only when ALL inputs resolve, or rejects immediately if any input rejects.

Promise.all

Promise Combinators

Promise.allSettled

Click to reveal
Promise Combinators
Static method that returns a Promise that always resolves after all input Promises settle, providing an array of outcome objects regardless of success or failure.

Promise.allSettled

Promise Combinators

Promise.race

Click to reveal
Promise Combinators
Static method that takes an iterable of Promises and returns a Promise that settles as soon as ANY input settles - whether it resolves or rejects.

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.

Audio Briefing
0:00 / 0:00

The Combinator Landscape

JavaScript gives us four Promise combinators. They work as a family. Understanding them together is faster than learning them in isolation:

CombinatorResolves whenRejects when
Promise.allEvery promise resolvesAny promise rejects
Promise.allSettledEvery promise settles (always)Never - always resolves
Promise.raceAny promise settlesAny promise rejects
Promise.anyAny promise resolvesEvery 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.

Advertisement

The Flawed Approach It Replaces

Here is what I used to write whenever I needed "fallback" behavior:

Old patternWhat 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
Advertisement

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.any is 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.message is always the literal string "All promises were rejected": do not parse it for debugging, use .errors instead.
  • Non-Promise iterable entries are coerced exactly like the other Promise methods (consistent behavior since ES2020).
  • Because Promise.any stops calling .then only 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 with AggregateError whose .errors array is empty. This differs from Promise.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

Test Your Knowledge

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement
Introduction to Test-Driven Development (TDD)
testing

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