JavaScript Promise Combinators

A single asynchronous operation is easy enough to manage with async/await, but real programs constantly need to coordinate many promises at once: fetching several API endpoints in parallel, racing a slow request against a timeout, or kicking off a batch of independent tasks and reporting on all of them even if some fail. JavaScript’s promise combinators – Promise.all(), Promise.allSettled(), Promise.race(), and Promise.any() – are static methods on the Promise constructor built exactly for this. Choosing the right one is less about memorizing syntax and more about understanding precisely when each settles, whether it can reject, and what value it hands back.

Overview: What Are Promise Combinators?

Each combinator accepts an iterable of promises (almost always an array) and returns a single new “aggregate” promise that describes how the whole group settles. They don’t run anything in a separate thread – JavaScript is single-threaded – the underlying async work (a timer, a network request, a file read) proceeds independently of the combinator itself, and the combinator’s job is purely bookkeeping: it attaches a .then()/.catch() handler to every promise in the collection and waits, via the microtask queue, for enough of them to settle before resolving or rejecting its own returned promise.

The four combinators differ only in their settlement rule:

  • Promise.all(iterable) resolves with an array of every fulfilled value, but only if all promises fulfill. If any one of them rejects, Promise.all() rejects immediately with that single reason – this is called “fail fast”.
  • Promise.allSettled(iterable) never rejects. It waits for every promise to settle (fulfilled or rejected) and resolves with an array of descriptor objects reporting each outcome.
  • Promise.race(iterable) settles as soon as the first promise settles – whether that promise fulfills or rejects – and simply adopts that outcome.
  • Promise.any(iterable) resolves with the value of the first promise to fulfill, ignoring rejections along the way. It only rejects if every promise rejects, in which case it rejects with a special AggregateError that bundles all the individual rejection reasons.

If you pass a non-promise value (like a plain number or string) inside the iterable, each combinator treats it as if it had been wrapped in Promise.resolve() – it “fulfills” instantly on the next microtask tick.

Syntax

Promise.all(iterable)
Promise.allSettled(iterable)
Promise.race(iterable)
Promise.any(iterable)

Every form takes one argument (an array of promises is the overwhelmingly common case) and returns one new promise. The table below summarizes the contract of each:

Method Fulfills when Rejects when Fulfilled value
Promise.all Every input promise fulfills Any single input promise rejects (fail fast) Array of values, in input order
Promise.allSettled Every input promise has settled (always) Never Array of {status, value} / {status, reason}
Promise.race The first promise to settle fulfills The first promise to settle rejects The value of whichever settled first
Promise.any The first promise to fulfill Every input promise rejects The value of the first fulfillment; rejection is an AggregateError

Examples

All of the examples below share a small helper, delay(ms, value, shouldReject), that returns a promise which resolves (or rejects, as a string message) with value after ms milliseconds. This makes the timing of each combinator easy to reason about.

Example 1: Promise.all with every promise fulfilling

function delay(ms, value, shouldReject = false) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (shouldReject) reject(`Failed: ${value}`);
      else resolve(value);
    }, ms);
  });
}

async function run() {
  const results = await Promise.all([
    delay(100, 'a'),
    delay(50, 'b'),
    delay(150, 'c'),
  ]);
  console.log(results);
}

run();
[ 'a', 'b', 'c' ]

Even though 'b' actually finishes first (50ms), then 'a' (100ms), then 'c' (150ms), the result array preserves the input order, not the completion order. That’s a core guarantee of Promise.all() and one reason array destructuring like const [user, posts] = await Promise.all([...]) is safe to rely on.

Example 2: Promise.all fails fast, Promise.allSettled waits for everyone

function delay(ms, value, shouldReject = false) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (shouldReject) reject(`Failed: ${value}`);
      else resolve(value);
    }, ms);
  });
}

async function withAll() {
  try {
    const results = await Promise.all([
      delay(100, 'a'),
      delay(50, 'b', true),
      delay(150, 'c'),
    ]);
    console.log('all resolved:', results);
  } catch (err) {
    console.log('all rejected:', err);
  }
}

async function withAllSettled() {
  const results = await Promise.allSettled([
    delay(100, 'a'),
    delay(50, 'b', true),
    delay(150, 'c'),
  ]);
  console.log('allSettled:', results);
}

withAll();
withAllSettled();
all rejected: Failed: b
allSettled: [
  { status: 'fulfilled', value: 'a' },
  { status: 'rejected', reason: 'Failed: b' },
  { status: 'fulfilled', value: 'c' }
]

Both groups start at the same time with the exact same three promises. Promise.all() gives up the moment 'b' rejects at 50ms – it doesn’t wait for 'a' or 'c', and their eventual results are simply discarded by Promise.all() itself (the underlying delay() timers still run to completion in the background). Promise.allSettled(), given the identical inputs, patiently waits until the slowest one, 'c' at 150ms, has also settled, then reports on all three – one rejection and two fulfillments – without ever rejecting itself.

Example 3: Promise.race resolves with whichever settles first

function delay(ms, value, shouldReject = false) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (shouldReject) reject(`Failed: ${value}`);
      else resolve(value);
    }, ms);
  });
}

async function run() {
  const winner = await Promise.race([
    delay(100, 'slow'),
    delay(20, 'fast'),
    delay(200, 'slower'),
  ]);
  console.log(winner);
}

run();
fast

Promise.race() doesn’t care about fulfillment vs. rejection – it just adopts whatever happens first. Here 'fast' resolves at 20ms, well before the other two, so the race promise fulfills with 'fast'. Had the fastest promise instead rejected, Promise.race() would have rejected with that same reason – this dual behavior is exactly what makes it useful for timeouts (see Best Practices below).

Example 4: Promise.any ignores rejections and returns the first success

function delay(ms, value, shouldReject = false) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (shouldReject) reject(`Failed: ${value}`);
      else resolve(value);
    }, ms);
  });
}

async function run() {
  try {
    const winner = await Promise.any([
      delay(50, 'x', true),
      delay(100, 'y'),
      delay(20, 'z', true),
    ]);
    console.log('First success:', winner);
  } catch (err) {
    console.log('All failed:', err.errors);
  }
}

run();
First success: y

Here two of the three promises reject ('z' at 20ms, 'x' at 50ms) but Promise.any() doesn’t stop for those – it keeps waiting until either a fulfillment shows up or everything has rejected. At 100ms 'y' finally fulfills, so that’s the value the combined promise resolves with. Unlike Promise.race(), an early rejection alone can never cause Promise.any() to reject.

Example 5: Promise.any rejects with an AggregateError when everything fails

function delay(ms, value, shouldReject = false) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (shouldReject) reject(`Failed: ${value}`);
      else resolve(value);
    }, ms);
  });
}

async function run() {
  try {
    await Promise.any([
      delay(10, 'x', true),
      delay(20, 'y', true),
    ]);
  } catch (err) {
    console.log(err.name);
    console.log(err.message);
    console.log(err.errors);
  }
}

run();
AggregateError
All promises were rejected
[ 'Failed: x', 'Failed: y' ]

When every candidate rejects, Promise.any() rejects with an AggregateError – a special error subclass whose .errors property is an array holding every individual rejection reason, in input order. This lets you inspect exactly why each source failed instead of only seeing the first (or last) failure.

Under the Hood: How Combinators Track Settlement

You can picture Promise.all()‘s internal implementation as keeping a results array pre-sized to the length of the input iterable, plus a countdown counter starting at that same length. It loops over every input promise and, for each one at index i, attaches a handler equivalent to promise.then(value => { results[i] = value; if (--remaining === 0) resolve(results); }, reason => reject(reason)). Because the value is written to results[i] using the original index rather than push order, the final array always mirrors input order regardless of which promise actually finishes first – that’s the mechanism behind Example 1. The moment any single handler’s rejection branch fires, it calls the outer reject() immediately, without waiting for the counter to reach zero – that’s the fail-fast behavior in Example 2.

Promise.allSettled() works almost identically, except every input promise gets both a fulfillment and a rejection handler that each build a descriptor object and decrement the same counter – there is no code path that calls the outer reject() at all, which is why it can never reject.

Promise.race() is the simplest: it attaches a bare .then(resolve, reject) to every input promise, and whichever one calls resolve or reject first “wins” – all the other handlers still fire later, but calling resolve() or reject() on an already-settled promise is a silent no-op, so only the first call has any effect.

Promise.any() flips the fail-fast logic of Promise.all() around: it resolves on the first fulfillment instead of the first rejection, and only rejects once every single promise has rejected, at which point it bundles all the collected reasons into one AggregateError and rejects with that. Because all of this bookkeeping happens inside .then()/.catch() callbacks, it always runs as a microtask – after the current synchronous code finishes but before the event loop moves on to timers or I/O callbacks – which is why awaiting any combinator never blocks other synchronous code from running first.

Common Mistakes

Mistake 1: Reaching for Promise.all() when partial success is acceptable. Code like const [user, posts, comments] = await Promise.all([fetchUser(), fetchPosts(), fetchComments()]) looks convenient, but if fetchComments() rejects, the whole expression throws – you lose access to the user and posts data even though those requests actually succeeded. If you want to attempt everything and inspect what worked, use Promise.allSettled() instead and check each descriptor’s status property, or attach a local .catch() to the individual promise you’re willing to have fail before it ever reaches Promise.all().

Mistake 2: Assuming Promise.race() cancels the losing promises. JavaScript promises cannot be cancelled once created. If you race a slow fetch() against a 5-second timeout promise, and the timeout wins, the fetch() keeps running in the background and will eventually settle on its own – if nothing is listening for its rejection, you can get an “unhandled promise rejection” warning later. It’s good practice to attach a no-op .catch(() => {}) to any promise you race but don’t otherwise use, so a later rejection doesn’t surface as noise.

Mistake 3: Forgetting how combinators behave on an empty array. Promise.all([]) resolves immediately with [], and Promise.allSettled([]) resolves immediately with [] too – there’s nothing to wait for. But Promise.any([]) rejects immediately with an AggregateError (there’s no way any promise can ever fulfill), while Promise.race([]) is the odd one out: it never settles at all, leaving you with a promise that stays pending forever. If you build the input array dynamically (say, from a list of URLs), guard against passing an empty list into Promise.race().

Best Practices

  • Reach for Promise.all() only when every result is genuinely required and any single failure should abort the whole operation.
  • Reach for Promise.allSettled() when you want to attempt everything independently and report on each outcome, even partial failures.
  • Use Promise.race() to implement timeouts: race your real operation against a promise that rejects after a delay via setTimeout.
  • Use Promise.any() when you have multiple redundant sources (mirrored endpoints, fallback servers) and only need the first one that succeeds.
  • Always attach a rejection handler to any promise you pass into a combinator but don’t individually await, so “losing” promises can’t trigger unhandled rejection warnings.
  • Build the input array with Array.prototype.map() over a list of ids or URLs rather than writing each promise call out by hand – it scales and stays readable.
  • When reading Promise.allSettled() results, filter by result.status === 'fulfilled' or 'rejected' rather than assuming position tells you the outcome.
  • Prefer async/await with these combinators over chained .then() calls – it keeps the control flow readable, especially once you add try/catch.

Practice Exercises

  • Write a function timeout(promise, ms) that uses Promise.race() to reject with an error like new Error('Timed out') if promise hasn’t settled within ms milliseconds, and otherwise resolves/rejects with whatever promise does.
  • Given three promises that simulate flaky mirror servers (use the delay() helper with different timings, some set to reject), use Promise.any() to log the first successful response, and log a friendly “all mirrors failed” message (inspecting err.errors) if every mirror rejects.
  • Write a function fetchAllReports(ids) that maps an array of ids to promises with Array.prototype.map(), uses Promise.allSettled() to wait for all of them, and returns two separate arrays: one of successful values and one of error reasons, by filtering on each descriptor’s status.

Summary

  • Promise.all() fulfills with all values in input order, but fails fast on the first rejection.
  • Promise.allSettled() always fulfills, once every promise has settled, with an array of status descriptors – it never rejects.
  • Promise.race() settles – fulfilled or rejected – as soon as the very first input promise settles.
  • Promise.any() fulfills with the first successful value and only rejects, with an AggregateError, if every input promise rejects.
  • None of the combinators can cancel the promises they’re given – unused “losing” promises keep running and should still be handled to avoid unhandled rejection warnings.
  • Empty-array edge cases differ: all and allSettled resolve to [], any rejects immediately, and race never settles.