Async Design Patterns
Almost every non-trivial Node.js program coordinates multiple asynchronous operations: database queries, HTTP requests, file reads, and timers. The order and degree of overlap you choose has a direct impact on latency, throughput, and how kindly your code treats downstream services. This page walks through the core async control-flow patterns — sequential, parallel, limited concurrency, async queues, and producer/consumer — using modern async/await and native promises.
Sequential execution
Sequential execution runs each task one after another, waiting for the previous one to settle before starting the next. Reach for it when later steps depend on earlier results, or when an external API forbids overlapping calls. With async/await, a plain for...of loop expresses this naturally — each await pauses the loop.
import { setTimeout as sleep } from "node:timers/promises";
async function fetchUser(id) {
await sleep(100); // simulate I/O latency
return { id, name: `User ${id}` };
}
async function loadSequentially(ids) {
const users = [];
for (const id of ids) {
const user = await fetchUser(id); // waits before next iteration
users.push(user);
}
return users;
}
const result = await loadSequentially([1, 2, 3]);
console.log(result);
Output:
[
{ id: 1, name: 'User 1' },
{ id: 2, name: 'User 2' },
{ id: 3, name: 'User 3' }
]
Avoid
array.forEach(async ...)for sequential work —forEachignores the returned promises, so iterations fire immediately and you lose ordering and error handling. Usefor...ofwithawaitinstead.
Parallel execution
When tasks are independent, run them concurrently and wait for all of them together. This collapses total latency from the sum of durations to the maximum. Promise.all is the workhorse: it resolves with an array of results in input order, and rejects as soon as any single promise rejects.
async function loadInParallel(ids) {
const promises = ids.map((id) => fetchUser(id)); // start all at once
return Promise.all(promises);
}
console.time("parallel");
await loadInParallel([1, 2, 3]);
console.timeEnd("parallel");
Output:
parallel: 103.412ms
If you need every result regardless of individual failures, use Promise.allSettled, which never short-circuits and reports each outcome.
| Combinator | Resolves when | Rejects when | Result shape |
|---|---|---|---|
Promise.all | all fulfill | first rejection | array of values |
Promise.allSettled | all settle | never | array of {status, value/reason} |
Promise.race | first settles | first rejects | single value/reason |
Promise.any | first fulfills | all reject (AggregateError) | single value |
Limited concurrency
Unbounded Promise.all over thousands of items can exhaust file descriptors, hammer a database connection pool, or trip rate limits. Limited concurrency caps how many tasks run at once. You can write a small pool yourself, or use a battle-tested library like p-limit.
async function mapWithConcurrency(items, limit, worker) {
const results = new Array(items.length);
let cursor = 0;
async function runWorker() {
while (cursor < items.length) {
const index = cursor++; // claim next item atomically (single-threaded)
results[index] = await worker(items[index], index);
}
}
const pool = Array.from({ length: Math.min(limit, items.length) }, runWorker);
await Promise.all(pool);
return results;
}
const ids = Array.from({ length: 10 }, (_, i) => i + 1);
const users = await mapWithConcurrency(ids, 3, fetchUser);
console.log(`Loaded ${users.length} users, 3 at a time`);
Output:
Loaded 10 users, 3 at a time
Each of the limit workers pulls the next index off a shared cursor. Because Node runs JavaScript on a single thread, the cursor++ is safe — no two workers ever grab the same index.
Async queues
An async queue decouples enqueuing work from processing it, applying back-pressure by bounding concurrency. It is the reusable, stateful sibling of the pool above: you push tasks in over time and the queue drains them at a fixed parallelism.
class AsyncQueue {
#concurrency;
#running = 0;
#queue = [];
constructor(concurrency = 1) {
this.#concurrency = concurrency;
}
push(task) {
return new Promise((resolve, reject) => {
this.#queue.push({ task, resolve, reject });
this.#next();
});
}
#next() {
while (this.#running < this.#concurrency && this.#queue.length > 0) {
const { task, resolve, reject } = this.#queue.shift();
this.#running++;
Promise.resolve()
.then(task)
.then(resolve, reject)
.finally(() => {
this.#running--;
this.#next();
});
}
}
}
const queue = new AsyncQueue(2);
const jobs = [1, 2, 3, 4, 5].map((id) =>
queue.push(() => fetchUser(id)).then((u) => console.log("done", u.id))
);
await Promise.all(jobs);
Output:
done 1
done 2
done 3
done 4
done 5
The
pushmethod returns a promise that settles when that specific task finishes, so callers can await individual results while the queue throttles overall throughput.
Producer/consumer with promises
The producer/consumer pattern separates the code that generates work from the code that handles it, connected by a buffer. With async iterators and a promise-based signal, a consumer can await items as a producer pushes them — ideal for streaming pipelines where you do not have the full list up front.
function createChannel() {
const buffer = [];
let notify;
let closed = false;
return {
push(value) {
buffer.push(value);
notify?.(); // wake any waiting consumer
},
close() {
closed = true;
notify?.();
},
async *[Symbol.asyncIterator]() {
while (true) {
if (buffer.length > 0) yield buffer.shift();
else if (closed) return;
else await new Promise((r) => (notify = r)); // park until pushed
}
},
};
}
const channel = createChannel();
// Producer
(async () => {
for (let i = 1; i <= 3; i++) {
await sleep(50);
channel.push(`event-${i}`);
}
channel.close();
})();
// Consumer
for await (const event of channel) {
console.log("handling", event);
}
Output:
handling event-1
handling event-2
handling event-3
The consumer’s for await...of loop transparently suspends on the channel’s async iterator whenever the buffer is empty and resumes the moment the producer pushes or closes.
Best Practices
- Prefer
for...ofwithawaitfor true sequential dependencies; usePromise.allover.map()when tasks are independent. - Never combine
awaitwithArray.prototype.forEach— it silently runs everything in parallel and swallows rejections. - Cap concurrency for any operation that touches a shared, finite resource (sockets, DB connections, rate-limited APIs).
- Use
Promise.allSettledwhen partial success is acceptable and you need to inspect every outcome. - Always attach error handling — an unhandled rejection inside a pool or queue can crash the process under default settings.
- Reach for proven libraries (
p-limit,p-queue,fastq) in production rather than maintaining bespoke schedulers. - Apply back-pressure in producer/consumer pipelines so a fast producer cannot exhaust memory ahead of a slow consumer.