Skip to content

Promise Combinators

intermediate14 min read

The Dashboard That Crashed on One Failed API

A dashboard loads data from four APIs in parallel:

const [users, orders, metrics, alerts] = await Promise.all([
  fetchUsers(),
  fetchOrders(),
  fetchMetrics(),
  fetchAlerts(),
]);

Works perfectly in development where all APIs are fast and reliable. In production, the alerts service goes down for 30 seconds. Promise.all rejects immediately. The entire dashboard shows an error — even though users, orders, and metrics are all fine.

The team switches to Promise.allSettled and renders what's available. Zero downtime for the user. The fix was literally one word — all to allSettled — but knowing when to use each combinator? That's the real skill.

The Mental Model

Mental Model

The four Promise combinators are like four different race formats:

  • Promise.all — A relay team. ALL runners must finish for the team to win. If any runner falls, the entire team is disqualified.
  • Promise.allSettled — A marathon with individual timers. Every runner completes (or drops out), and you get each runner's result regardless.
  • Promise.race — A sprint. First runner to cross the finish line wins (or first to fall counts as the result). Everyone else is ignored.
  • Promise.any — A sprint where only finishers count. The first runner to successfully finish wins. Fallers are ignored unless ALL runners fall.

Promise.all

Signature: Promise.all(iterable) returns a promise that:

  • Fulfills with an array of all values when ALL promises fulfill
  • Rejects with the first rejection reason when ANY promise rejects
// All succeed → array of results
const results = await Promise.all([
  Promise.resolve(1),
  Promise.resolve(2),
  Promise.resolve(3),
]);
// results = [1, 2, 3]

// One fails → immediate rejection
try {
  await Promise.all([
    Promise.resolve(1),
    Promise.reject(new Error('fail')),
    Promise.resolve(3),  // this still completes, but its result is lost
  ]);
} catch (err) {
  // err.message === 'fail'
}

The "Fail Fast" Gotcha

This is the part that surprises people. When Promise.all rejects, the other promises keep running. They aren't cancelled. Their results are just ignored:

await Promise.all([
  fetch('/api/users'),    // still in-flight after rejection
  fetch('/api/orders'),   // rejects with NetworkError
  fetch('/api/metrics'),  // still in-flight after rejection
]);
// The fetch calls for users and metrics continue to completion.
// They consume bandwidth and server resources — they just aren't awaited.

There is no built-in way to cancel the other promises. If you need cancellation, combine with AbortController:

const controller = new AbortController();

try {
  await Promise.all([
    fetch('/api/users', { signal: controller.signal }),
    fetch('/api/orders', { signal: controller.signal }),
    fetch('/api/metrics', { signal: controller.signal }),
  ]);
} catch (err) {
  controller.abort(); // cancel remaining requests
}
Quiz
Promise.all rejects when one promise fails. What happens to the other promises?

Promise.allSettled

Signature: Promise.allSettled(iterable) returns a promise that always fulfills (never rejects) with an array of result objects:

const results = await Promise.allSettled([
  Promise.resolve(1),
  Promise.reject(new Error('fail')),
  Promise.resolve(3),
]);

// results = [
//   { status: 'fulfilled', value: 1 },
//   { status: 'rejected', reason: Error('fail') },
//   { status: 'fulfilled', value: 3 },
// ]

Each result is a discriminated union on the status field:

type SettledResult<T> =
  | { status: 'fulfilled'; value: T }
  | { status: 'rejected'; reason: unknown };

When to Use allSettled Over all

Use Promise.allSettled when:

  • Partial results are acceptable (dashboards, non-critical data)
  • You need to know which specific promises failed
  • Independent operations that shouldn't affect each other
  • You want to run cleanup for each promise individually

Use Promise.all when:

  • ALL results are required (transactional operations)
  • Any failure should abort the entire operation
  • Results depend on each other
// Dashboard: show what's available
const results = await Promise.allSettled([
  fetchUsers(),
  fetchOrders(),
  fetchMetrics(),
]);

const data = {};
for (const [i, result] of results.entries()) {
  if (result.status === 'fulfilled') {
    data[['users', 'orders', 'metrics'][i]] = result.value;
  }
}
// Render dashboard with whatever data we have

Promise.race

Signature: Promise.race(iterable) returns a promise that settles with the first promise to settle — whether fulfilled or rejected:

// First to settle wins
const result = await Promise.race([
  new Promise(resolve => setTimeout(() => resolve('slow'), 1000)),
  new Promise(resolve => setTimeout(() => resolve('fast'), 100)),
]);
// result = 'fast'

// Rejection also wins if it's first
try {
  await Promise.race([
    new Promise((_, reject) => setTimeout(() => reject('error'), 50)),
    new Promise(resolve => setTimeout(() => resolve('slow'), 1000)),
  ]);
} catch (err) {
  // err === 'error'
}

The Timeout Pattern

Turns out, Promise.race is most commonly used for timeouts:

function withTimeout(promise, ms) {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms)
  );
  return Promise.race([promise, timeout]);
}

try {
  const data = await withTimeout(fetch('/api/slow'), 5000);
} catch (err) {
  if (err.message.startsWith('Timed out')) {
    // Handle timeout
  }
}
Common Trap

The timeout pattern has a subtle leak: the original fetch continues running after the timeout. If you're making network requests, use AbortController with a timeout signal instead: fetch(url, { signal: AbortSignal.timeout(5000) }). This actually cancels the request rather than just ignoring it.

Quiz
What does Promise.race([]) (empty array) return?

Promise.any

Signature: Promise.any(iterable) returns a promise that fulfills with the first fulfilled value. Rejections are ignored unless ALL promises reject.

// First success wins, errors are ignored
const result = await Promise.any([
  Promise.reject('error 1'),
  Promise.resolve('success'),
  Promise.reject('error 2'),
]);
// result = 'success'

// ALL fail → AggregateError
try {
  await Promise.any([
    Promise.reject('error 1'),
    Promise.reject('error 2'),
    Promise.reject('error 3'),
  ]);
} catch (err) {
  // err is an AggregateError
  // err.errors = ['error 1', 'error 2', 'error 3']
}

When to Use Promise.any

The classic use case -- and honestly, one of the most elegant patterns in async JavaScript: trying multiple sources for redundancy.

// Try multiple CDNs — first to respond wins
const image = await Promise.any([
  fetchImage('https://cdn1.example.com/photo.jpg'),
  fetchImage('https://cdn2.example.com/photo.jpg'),
  fetchImage('https://cdn3.example.com/photo.jpg'),
]);

Other use cases:

  • Racing multiple DNS resolvers
  • Trying cache then network (whoever responds first)
  • Fallback service endpoints

Combinator Comparison

Fulfills whenRejects whenEmpty iterable
Promise.allALL fulfillANY rejects (fail-fast)Fulfills with []
Promise.allSettledALL settle (always fulfills)Never rejectsFulfills with []
Promise.raceFirst to settle (fulfill or reject)First to settle is rejectedNever settles
Promise.anyFirst to fulfillALL reject (AggregateError)Rejects with AggregateError

Production Scenario: Resilient Data Loading

Here's how you actually use these in a real codebase. A production dashboard needs data from three services. Some are critical (must have), some are optional (nice to have):

async function loadDashboard() {
  // Critical data: use Promise.all — fail if any fails
  const [user, permissions] = await Promise.all([
    fetchUser(),
    fetchPermissions(),
  ]);

  // Optional data: use Promise.allSettled — render what's available
  const optionalResults = await Promise.allSettled([
    fetchRecentActivity(),
    fetchRecommendations(),
    fetchNotificationCount(),
  ]);

  const optional = {};
  const labels = ['activity', 'recommendations', 'notifications'];
  for (let i = 0; i < optionalResults.length; i++) {
    if (optionalResults[i].status === 'fulfilled') {
      optional[labels[i]] = optionalResults[i].value;
    }
  }

  return { user, permissions, ...optional };
}

This pattern gives you: hard failure on critical data, graceful degradation on optional data, and maximum parallelism for both.

Promise.all with concurrency limits

Promise.all starts all promises immediately. For 1000 API calls, that's 1000 concurrent requests — which will overwhelm most servers. There's no built-in concurrency limit. For controlled concurrency, you need a utility:

async function allWithConcurrency(tasks, limit) {
  const results = [];
  const executing = new Set();

  for (const [i, task] of tasks.entries()) {
    const p = task().then(val => { executing.delete(p); return val; });
    executing.add(p);
    results[i] = p;

    if (executing.size >= limit) {
      await Promise.race(executing);
    }
  }

  return Promise.all(results);
}

// Process 100 URLs, max 5 concurrent
const results = await allWithConcurrency(
  urls.map(url => () => fetch(url)),
  5
);

Common Mistakes

What developers doWhat they should do
Using Promise.all for independent operations where partial failure is acceptable
Promise.all is all-or-nothing. One failure discards all successful results. For dashboards, batch operations, or multi-source loading, allSettled preserves successful results.
Use Promise.allSettled when individual failures shouldn't crash the whole operation.
Assuming Promise.race cancels the losing promises
Promises aren't cancellable. The 'losing' promises still consume resources. Use AbortController if you need actual cancellation.
All promises in a race continue running. Only the result of the first is used.
Passing non-Promise values without realizing they count
Promise.race([42, slowFetch()]) always returns 42 because the non-Promise value 'wins' instantly. This is per spec but can cause subtle bugs.
Non-Promise values in combinators are treated as Promise.resolve(value) — they fulfill immediately.
Catching AggregateError from Promise.any without reading .errors
err.message for AggregateError is generic ('All promises were rejected'). The useful information is in err.errors — an array of each promise's rejection reason.
AggregateError has an .errors property containing all individual rejection reasons. Always inspect it.

Challenge: Which Combinator?

Challenge: Pick the Right Combinator

// Scenario: You're building a function that:
// 1. Fetches a user's avatar from 3 mirror servers
// 2. Returns the first successful response
// 3. If ALL mirrors fail, throws with all errors
//
// Which combinator do you use and why?
// Write the implementation.
Show Answer

Use Promise.any — it fulfills with the first success and only rejects when ALL promises reject.

async function fetchAvatar(userId) {
  const mirrors = [
    `https://cdn1.example.com/avatars/${userId}.jpg`,
    `https://cdn2.example.com/avatars/${userId}.jpg`,
    `https://cdn3.example.com/avatars/${userId}.jpg`,
  ];

  try {
    return await Promise.any(
      mirrors.map(url => fetch(url).then(res => {
        if (!res.ok) throw new Error(`${url}: ${res.status}`);
        return res.blob();
      }))
    );
  } catch (err) {
    // AggregateError — all mirrors failed
    console.error('All mirrors failed:', err.errors);
    throw new Error('Avatar unavailable from all sources');
  }
}

Why not the others?

  • Promise.all — fails on first error, but we want to tolerate individual failures
  • Promise.allSettled — waits for ALL to complete, but we want the fastest success
  • Promise.race — would return a rejection if the fastest mirror fails, even if others succeed

Key Rules

Key Rules
  1. 1Promise.all: use when ALL results are needed. Fails fast on first rejection. Best for transactional operations.
  2. 2Promise.allSettled: use when results are independent. Never rejects. Best for dashboards, batch operations, partial-failure tolerance.
  3. 3Promise.race: settles with the first promise to settle (success OR failure). Best for timeouts and first-response-wins patterns.
  4. 4Promise.any: settles with the first SUCCESS. Ignores rejections unless all fail. Best for redundancy and fallback patterns.
  5. 5No combinator cancels losing promises. Combine with AbortController when you need actual cancellation of in-flight work.