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 taggedfulfilledorrejected. 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
// 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.
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.
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.
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