Concurrency Patterns with Promises

Sequential vs parallel, and the four Promise combinators you should reach for.

The single most common performance bug in async Node code is accidental serialization: awaiting things one at a time that could have run together. Independent async work should be started together and awaited together.

// Slow: 3 sequential round trips (~300ms if each is ~100ms)
const a = await getUser(1);
const b = await getUser(2);
const c = await getUser(3);

// Fast: 3 concurrent round trips (~100ms)
const [a2, b2, c2] = await Promise.all([
  getUser(1), getUser(2), getUser(3),
]);

The four combinators

  • Promise.all — all succeed, or reject on the first failure. Fail-fast.
  • Promise.allSettled — wait for every result, each tagged fulfilled or rejected. Never rejects.
  • Promise.race — settles with the first to settle (fulfil or reject). Great for timeouts.
  • Promise.any — first fulfilment; rejects only if all reject (AggregateError).

Bounded concurrency

Unbounded Promise.all over 10,000 items will open 10,000 sockets or file handles at once and fall over. In production you want a concurrency limit — a worker-pool over the queue.

Example

Example · javascript
// A tiny, dependency-free bounded-concurrency map.
// Runs `worker` over `items` with at most `limit` in flight.
async function mapLimit(items, limit, worker) {
  const results = new Array(items.length);
  let next = 0;

  async function run() {
    while (next < items.length) {
      const i = next++;            // claim an index atomically (single-threaded)
      results[i] = await worker(items[i], i);
    }
  }

  // Start `limit` runners; each pulls the next item until the queue drains.
  const runners = Array.from({ length: Math.min(limit, items.length) }, run);
  await Promise.all(runners);
  return results;
}

// Usage: fetch 1000 URLs, but never more than 8 concurrent connections.
const urls = Array.from({ length: 1000 }, (_, i) => `/api/item/${i}`);
const data = await mapLimit(urls, 8, async (url) => {
  const res = await fetch(url);
  return res.json();
});
console.log('fetched', data.length, 'items with max 8 in flight');

When to use it

  • A payment service uses `Promise.allSettled` to attempt charging three payment methods in parallel and reports which succeeded and which failed.
  • A data migration runs jobs in controlled batches of 10 using a concurrency limiter so the database is not overwhelmed by parallel queries.
  • An API aggregator uses `Promise.race` to return the first successful response from three regional endpoints and cancel the slower ones.

More examples

Promise.allSettled for partial failures

Uses `Promise.allSettled` to run all promises and inspect each result individually, even if some reject.

Example · js
const jobs = [fetch('/a'), fetch('/b'), fetch('/broken')]
const results = await Promise.allSettled(jobs);
results.forEach(({ status, value, reason }) => {
  if (status === 'fulfilled') console.log('OK:', value.url);
  else                        console.error('ERR:', reason.message);
});

Concurrency limiter with p-limit

Uses `p-limit` to cap concurrent fetch calls at 5, preventing socket exhaustion when processing many URLs.

Example · js
const pLimit = require('p-limit');
const limit  = pLimit(5); // max 5 concurrent promises

const urls = Array.from({ length: 50 }, (_, i) => `https://api.example.com/item/${i}`);

const results = await Promise.all(
  urls.map(url => limit(() => fetch(url).then(r => r.json())))
);

Async generator for streaming results

Uses an async generator to lazily paginate an API -- items are fetched on-demand as the `for await` loop iterates.

Example · js
async function* paginate(baseUrl) {
  let page = 1;
  while (true) {
    const res  = await fetch(`${baseUrl}?page=${page}`);
    const data = await res.json();
    if (!data.items.length) return;
    yield data.items;
    page++;
  }
}

for await (const items of paginate('/api/products')) {
  console.log('Page of', items.length, 'items');
}

Discussion

  • Be the first to comment on this lesson.