Medium JavaScript

Promise.all vs allSettled vs race vs any: Semantics, Error Handling, Timeouts and Concurrency Limits

One flaky service took down a whole dashboard through Promise.all. Learn what each combinator really does, how to add timeouts, and how to cap concurrency.

03 Nov, 2026 10 min Updated 23 Oct, 2026 6 Code blocks
Diagram
graph TD A[Several async tasks] --> B{Need every result} B -->|one failure ruins all| C[Promise.all] B -->|partial results are fine| D[Promise.allSettled] A --> E{Need just one result} E -->|first to settle wins| F[Promise.race] E -->|first success wins| G[Promise.any] A --> H[Many items: add a concurrency limit]

The account dashboard loads six widgets: profile, orders, invoices, recommendations, notifications and loyalty points. Someone wrapped all six calls in Promise.all because it was faster than awaiting them one by one. Then the recommendations service had a bad afternoon, and every dashboard request returned a 500, even though the other five services were perfectly healthy. The same week, a nightly job did Promise.all(customers.map(sendEmail)) over 8,000 customers, opened thousands of sockets at once and got rate limited by the email provider within seconds.

Both incidents come from the same gap: treating the promise combinators as interchangeable "run things in parallel" helpers. They are not. Promise.all, Promise.allSettled, Promise.race and Promise.any differ in when they settle, what they do with failures, and what they return. None of them limits concurrency, and none of them cancels anything. Pick the right one and add the missing pieces yourself.

The Four Combinators at a Glance

All four take an iterable of promises (or plain values) and return a single promise. The difference is the rule for settling:

Combinator Fulfils when Rejects when Result Empty input
Promise.all every input fulfils the first input rejects array of values, input order fulfils with []
Promise.allSettled every input settles never array of { status, value / reason } fulfils with []
Promise.race the first input settles, if it fulfils the first input settles, if it rejects that single value or reason stays pending forever
Promise.any the first input fulfils every input rejects first value, or AggregateError rejects with AggregateError

Two facts apply to all of them and cause most real bugs:

  1. The work starts before the combinator is called. fetch(url) starts the request the moment you call it. Promise.all only observes promises that are already running. That is why Promise.all(customers.map(sendEmail)) fires 8,000 requests at once: the map started them all.
  2. Nothing gets cancelled. When Promise.all rejects early, or Promise.race picks a winner, the other operations keep running to completion. Their results are just ignored. If you need cancellation, you need AbortController on top.

Choosing the Right One

graph TD A[Several async tasks] --> B{Need every result} B -->|one failure ruins all| C[Promise.all] B -->|partial results are fine| D[Promise.allSettled] A --> E{Need just one result} E -->|first to settle wins| F[Promise.race] E -->|first success wins| G[Promise.any] A --> H[Many items: add a concurrency limit]

Promise.all: All or Nothing, Fail Fast

Use Promise.all when every result is required and one failure means the whole operation is meaningless. Building an invoice needs the customer, the line items and the tax rates; if any is missing, there is no invoice.

async function buildInvoice(orderId: string) {
  const [customer, items, taxRates] = await Promise.all([
    customers.forOrder(orderId),
    orderItems.list(orderId),
    tax.ratesFor(orderId),
  ]);

  return renderInvoice(customer, items, taxRates);
}

The result array is in input order, not completion order, and destructuring keeps the types precise in TypeScript. The promise rejects with the first rejection reason as soon as it happens, which is great for latency: you do not wait for slow siblings when the answer is already "error".

The flip side: the other promises are still running and, if they reject later, those rejections are handled by Promise.all internally, so you will not get unhandled rejection warnings, but you also will not hear about them. If a sibling had side effects (writes, payments), they happen anyway.

Promise.allSettled: Partial Results Are Fine

The dashboard is the classic case for allSettled: each widget is independent, and a broken widget should render as "unavailable", not take the whole page down.

type Widget = 'profile' | 'orders' | 'recommendations';

async function loadDashboard(userId: string) {
  const loaders: Record<Widget, () => Promise<unknown>> = {
    profile: () => api.profile(userId),
    orders: () => api.orders(userId),
    recommendations: () => api.recommendations(userId),
  };

  const names = Object.keys(loaders) as Widget[];
  const results = await Promise.allSettled(names.map((name) => loaders[name]()));

  return Object.fromEntries(
    results.map((result, i) => {
      if (result.status === 'fulfilled') {
        return [names[i], { ok: true, data: result.value }];
      }

      logger.warn('widget failed', { widget: names[i], reason: String(result.reason) });

      return [names[i], { ok: false, data: null }];
    }),
  );
}

allSettled never rejects, which means you are responsible for looking at the failures. A common mistake is to call allSettled, map over value, and silently render undefined for failed entries. Always branch on status, and log or count the reason, or you lose all visibility into failures. In TypeScript, narrowing on result.status === 'fulfilled' gives you PromiseFulfilledResult<T>, so result.value is typed.

Promise.race: Timeouts, and the Timer You Forgot

race settles with whichever input settles first, fulfilled or rejected. Its main practical use is a timeout around an operation that does not support one natively:

export function withTimeout<T>(promise: Promise<T>, ms: number, label = 'operation'): Promise<T> {
  let timer: ReturnType<typeof setTimeout> | undefined;

  const timeout = new Promise<never>((_, reject) => {
    timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
  });

  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}

Two things are easy to get wrong here. First, the clearTimeout in finally: without it, every successful call leaves a live timer behind, which keeps a Node process alive at shutdown and, in a long-running server, means thousands of pending timers. Second, the timeout only stops you from waiting. The underlying operation is still running. For fetch, pass AbortSignal.timeout(ms) as the signal instead so the request is really cancelled; keep the race pattern for things that cannot be aborted, like a third-party SDK call.

Also remember that race with a rejection wins too. If the operation fails in 5 ms, the race rejects with that error immediately, which is usually what you want.

Promise.any: First Success Wins

any ignores rejections until every input has rejected. It fits redundancy: query three mirrors or replicas and use whichever answers successfully first.

async function fetchFromFastestMirror(path: string): Promise<unknown> {
  const mirrors = ['https://eu.cdn.example.com', 'https://us.cdn.example.com', 'https://ap.cdn.example.com'];

  try {
    return await Promise.any(
      mirrors.map(async (base) => {
        const res = await fetch(`${base}${path}`);
        if (!res.ok) {
          throw new Error(`${base} answered ${res.status}`);
        }
        return res.json();
      }),
    );
  } catch (error) {
    if (error instanceof AggregateError) {
      console.error('all mirrors failed', error.errors.map(String));
    }
    throw error;
  }
}

Note the if (!res.ok) throw. fetch only rejects on network errors, not on HTTP 500, so without that check a mirror that quickly returns an error page would "win". AggregateError.errors holds every individual reason in input order, which is exactly what you want in the log line. Using any here triples the load on your mirrors, so reserve it for small, idempotent reads.

Concurrency Limiting: What None of Them Do

For the 8,000 emails, none of the combinators help, because the work already started inside map. The fix is to control when each task starts: a small pool of workers that pull the next item from a shared index.

export async function mapWithConcurrency<T, R>(
  items: readonly T[],
  limit: number,
  fn: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
  if (!Number.isInteger(limit) || limit < 1) {
    throw new RangeError('limit must be a positive integer');
  }

  const results = new Array<R>(items.length);
  let next = 0;
  let failed = false;

  async function worker(): Promise<void> {
    while (!failed && next < items.length) {
      const index = next++;
      try {
        results[index] = await fn(items[index], index);
      } catch (error) {
        failed = true;
        throw error;
      }
    }
  }

  const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
  await Promise.all(workers);

  return results;
}

Because JavaScript is single-threaded, const index = next++ is atomic; no lock is needed. Results go into results[index], so output order matches input order regardless of which task finishes first. The failed flag gives fail-fast semantics like Promise.all, and also stops the pool from starting new items after the first error, which matters when each item sends an email or charges a card. If you prefer collect-all semantics, catch inside the worker and store { ok: false, error } instead.

Libraries like p-limit and p-map implement the same idea with more options. The point is to understand the shape: a fixed number of in-flight promises, not a fixed number of batches. Chunking into groups of 50 with Promise.all per chunk works, but each chunk waits for its slowest member, so throughput drops to the speed of the worst request in every batch.

Testing Combinator Logic with Vitest

Concurrency code is where "it worked on my machine" lives. Test the properties that matter: peak concurrency, order, and failure behaviour. Fake timers make timeout tests instant and deterministic.

import { afterEach, describe, expect, it, vi } from 'vitest';
import { mapWithConcurrency, withTimeout } from './async-utils';

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

describe('mapWithConcurrency', () => {
  it('never runs more than limit tasks and keeps input order', async () => {
    let active = 0;
    let peak = 0;

    const out = await mapWithConcurrency([30, 10, 20, 5], 2, async (ms) => {
      active++;
      peak = Math.max(peak, active);
      await sleep(ms);
      active--;
      return ms * 2;
    });

    expect(out).toEqual([60, 20, 40, 10]);
    expect(peak).toBe(2);
  });

  it('rejects with the first error and starts no new work', async () => {
    const started: number[] = [];

    await expect(
      mapWithConcurrency([1, 2, 3, 4], 1, async (n) => {
        started.push(n);
        if (n === 2) throw new Error('boom');
        return n;
      }),
    ).rejects.toThrow('boom');

    expect(started).toEqual([1, 2]);
  });
});

describe('withTimeout', () => {
  afterEach(() => vi.useRealTimers());

  it('rejects when the promise is too slow', async () => {
    vi.useFakeTimers();
    const pending = withTimeout(new Promise(() => {}), 1000, 'report');
    const assertion = expect(pending).rejects.toThrow('report timed out after 1000ms');

    await vi.advanceTimersByTimeAsync(1000);
    await assertion;
  });
});

Attach the rejects assertion before advancing the timers; otherwise the rejection happens with no handler attached and Vitest reports it as unhandled.

Common Pitfalls

  • Expecting Promise.all to cancel siblings. It does not. Pass an AbortSignal to the operations and abort it in a catch if you need that.
  • Promise.all over an unbounded array. items.map(asyncFn) starts everything at once. Use a concurrency limit for anything that touches the network or the database.
  • Ignoring allSettled failures. It never rejects, so errors vanish unless you check status and log reason.
  • Promise.race([]) hangs forever. If the list can be empty, guard it.
  • Timeouts that leak timers. Always clear the timer in finally.
  • Treating HTTP errors as success. fetch resolves on 4xx and 5xx. Check res.ok before handing the promise to any or all.
  • Sequential await in a loop when work is independent. for (const id of ids) await load(id) takes the sum of all latencies. That is fine when order or rate matters, but it is a common accidental slowdown.

When to Use Which, and When Not To

  • Promise.all: a fixed, small set of required results, where any failure invalidates the whole. Not for large or user-controlled lists without a concurrency limit.
  • Promise.allSettled: independent tasks where partial success is acceptable and you will report failures individually. Not when one failure should stop the rest quickly; it always waits for the slowest.
  • Promise.race: timeouts and "whichever settles first" signals, such as racing work against a shutdown signal. Not for picking the fastest successful source, because a fast failure wins.
  • Promise.any: redundant, idempotent reads where any success is good enough. Not for writes, and not when the extra load on the backends is a problem.
  • A concurrency limiter: any batch job over many items. Pair it with all-style or allSettled-style error handling depending on whether one failure should stop the batch.

The combinators answer "how do I wait?". Concurrency limits and cancellation answer "how much do I start?" and "how do I stop?". Production code usually needs all three.

Interactive challenge

Try the challenge

Practice what you just learned. Write your solution, reveal hints if you get stuck.

Instructions

Implement a concurrency-limited map. JavaScript: async function mapWithConcurrency(items, limit, fn). Python: async def map_with_concurrency(items, limit, fn). - At most `limit` calls of fn may be in flight at the same time, and the limit must actually be used (with enough slow items, exactly `limit` run at once). - JavaScript calls fn(item, index); Python calls fn(item). Both await the returned promise/coroutine. - Resolve/return an array/list of results in INPUT order, not completion order. An empty input gives an empty array. - If any call fails, reject/raise with that first error, and do not start any item that was not already started. - If limit is not a positive integer, reject with RangeError (JavaScript) or raise ValueError (Python).

Starter code

javascript
/**
 * Run fn over items with at most `limit` calls in flight.
 * Resolves to results in input order; rejects with the first error
 * and starts no new items after a failure.
 */
async function mapWithConcurrency(items, limit, fn) {
  // TODO: reject with RangeError when limit is not a positive integer
  // TODO: start min(limit, items.length) workers; each worker repeatedly takes
  //       the next unclaimed index and awaits fn(items[index], index)
  // TODO: store each result at its own index so output order matches input order
  // TODO: after the first error, stop starting new items and reject with that error
  throw new Error('TODO');
}
python
import asyncio
from collections.abc import Awaitable, Callable, Sequence


async def map_with_concurrency[T, R](
    items: Sequence[T], limit: int, fn: Callable[[T], Awaitable[R]]
) -> list[R]:
    """Await fn(item) for every item with at most `limit` calls in flight.

    Returns results in input order. Re-raises the first error and starts
    no new items after a failure.
    """
    # TODO: raise ValueError when limit is not a positive int
    # TODO: run min(limit, len(items)) worker coroutines with asyncio.gather;
    #       each worker claims the next index and awaits fn(items[index])
    # TODO: store results by index; after the first error stop claiming new items
    raise NotImplementedError

Your solution

Hints

All hints revealed
Hint 1

Start min(limit, items.length) workers. Each worker loops: claim index = next++, await fn on that item, store the result at results[index].

Hint 2

JavaScript is single-threaded, so next++ cannot race between workers. In Python the same holds between awaits inside one event loop.

Hint 3

Keep a shared failed flag: set it in catch before rethrowing, and check it in the loop condition so no new items start.

Hint 4

Combine the workers with Promise.all in JavaScript and asyncio.gather in Python.

Solution · javascript
/**
 * Run fn over items with at most `limit` calls in flight.
 * Resolves to results in input order; rejects with the first error
 * and starts no new items after a failure.
 */
async function mapWithConcurrency(items, limit, fn) {
  if (!Number.isInteger(limit) || limit < 1) {
    throw new RangeError('limit must be a positive integer');
  }

  const results = new Array(items.length);
  let next = 0;
  let failed = false;

  async function worker() {
    while (!failed && next < items.length) {
      const index = next++;
      try {
        results[index] = await fn(items[index], index);
      } catch (error) {
        failed = true;
        throw error;
      }
    }
  }

  const workers = [];
  for (let i = 0; i < Math.min(limit, items.length); i++) {
    workers.push(worker());
  }
  await Promise.all(workers);

  return results;
}
Solution · python
import asyncio
from collections.abc import Awaitable, Callable, Sequence


async def map_with_concurrency[T, R](
    items: Sequence[T], limit: int, fn: Callable[[T], Awaitable[R]]
) -> list[R]:
    """Await fn(item) for every item with at most `limit` calls in flight.

    Returns results in input order. Re-raises the first error and starts
    no new items after a failure.
    """
    if not isinstance(limit, int) or limit < 1:
        raise ValueError("limit must be a positive integer")

    results: list = [None] * len(items)
    next_index = 0
    failed = False

    async def worker() -> None:
        nonlocal next_index, failed
        while not failed and next_index < len(items):
            index = next_index
            next_index += 1
            try:
                results[index] = await fn(items[index])
            except BaseException:
                failed = True
                raise

    await asyncio.gather(*(worker() for _ in range(min(limit, len(items)))))
    return results