Back to JavaScript
JavaScript
medium
mid

How do you handle multiple asynchronous operations using Promises?

A Promise is a placeholder for a future value — pending → fulfilled or rejected, settled once. For multiple async ops: `Promise.all` (fail-fast parallel), `Promise.allSettled` (parallel, never rejects, returns per-result status), `Promise.race` (first to settle), `Promise.any` (first to fulfill, ignores rejections). Use `for await…of` or a worker pool when you need bounded concurrency.

6 min read·~15 min to think through

A Promise is a wrapper around an async result — an object whose state moves from pending to either fulfilled (with a value) or rejected (with a reason). Once settled, it's immutable. .then queues a callback for fulfillment, .catch for rejection, .finally for either. async/await is syntactic sugar — every async function returns a Promise.

The four combinators — know when to reach for each.

ts
Promise.all([a, b, c])         // fulfilled with [va, vb, vc]; rejects on first failure
Promise.allSettled([a, b, c])  // always fulfills with [{status, value|reason}, ...]
Promise.race([a, b, c])        // settles with whatever settles first (fulfill OR reject)
Promise.any([a, b, c])         // fulfills with first SUCCESS; rejects only if all fail (AggregateError)

Decision rule.

  • All must succeed → Promise.all (and you'll catch the first error).
  • All should run, you want every outcome → Promise.allSettled (analytics, batch operations).
  • Try multiple sources, take the fastest success → Promise.any (mirror downloads, failover).
  • Timeout pattern → Promise.race([fetch(), timeout(5000)]).

Parallel vs serial. Two common bugs:

js
// Bug — serial. Each await blocks the next.
for (const url of urls) {
  results.push(await fetch(url));
}

// Fix — parallel.
const results = await Promise.all(urls.map(u => fetch(u)));

But the parallel version unbounded can swamp the network with 10,000 requests. For bounded concurrency, use a small pool:

js
async function pool<T, R>(items: T[], n: number, fn: (t: T) => Promise<R>) {
  const out: R[] = new Array(items.length);
  let i = 0;
  async function worker() {
    while (i < items.length) {
      const idx = i++;
      out[idx] = await fn(items[idx]);
    }
  }
  await Promise.all(Array.from({ length: n }, worker));
  return out;
}

This caps in-flight work at n — typical values 5–20 for HTTP, 1 for sequential DB writes.

The four senior details.

  1. Don't await inside Promise.all's map callback unless you mean it. urls.map(async u => { const r = await fetch(u); return parse(r); }) is fine — each still runs in parallel, the awaits are independent.
  1. Unhandled rejections crash Node and log warnings in browsers. Always have a .catch or try/catch at the outermost level. In React, an unhandled rejection in an effect won't trigger an error boundary unless you funnel it through state.
  1. **Promise.all rejects fast but doesn't cancel siblings.** They still run. If you need cancellation, pass an AbortController and have each task respect the signal.
  1. async functions always return a Promise, even async () => 1. return inside async resolves with the value; throw rejects. There's no synchronous escape hatch.

Common micro-mistakes.

  • return await x is identical to return x in most cases — except inside try/catch, where return await lets the catch handle a rejection. Otherwise the wrapper function returns the rejected promise to the caller.
  • new Promise((resolve) => doAsync(resolve)) is fine for wrapping callbacks; never wrap an already-returning Promise in new Promise — that's the explicit-construction antipattern.
  • Promises are eager. The moment you create fetch(url), the request fires. They are not lazy like generators.

Follow-up questions

  • Difference between Promise.all and Promise.allSettled — when to use each?
  • How does async/await desugar to .then chains?
  • Implement Promise.all from scratch.
  • What is the microtask queue and how does it interact with Promises?

Common mistakes

  • Serial awaits in a loop when parallel was intended.
  • Using `Promise.all` for batch operations where a single failure shouldn't abort the rest.
  • Forgetting that rejected siblings of Promise.all still run to completion.
  • Wrapping an existing Promise in `new Promise` (explicit construction antipattern).

Performance considerations

  • Unbounded parallelism (`Promise.all` on 10k items) can saturate connections; use a pool.
  • Microtask queue runs to exhaustion between macrotasks — heavy `.then` chains can starve rendering.

Edge cases

  • Empty array — `Promise.all([])` resolves to `[]` immediately; `Promise.any([])` rejects with AggregateError.
  • Mixing non-Promise values into combinators — they're wrapped via `Promise.resolve`.
  • Promises chained without `await` lose their context in async stack traces unless `--async-stack-traces` is on.

Real-world examples

  • Fetching N microservice calls on a page load: `Promise.all`.
  • Batch-uploading 1000 files with rate limiting: bounded pool.
  • Geo-fallback lookups: `Promise.any([cdn1, cdn2, cdn3])`.

Related questions