Skip to content
Node.js nd patterns 5 min read

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 — forEach ignores the returned promises, so iterations fire immediately and you lose ordering and error handling. Use for...of with await instead.

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.

CombinatorResolves whenRejects whenResult shape
Promise.allall fulfillfirst rejectionarray of values
Promise.allSettledall settleneverarray of {status, value/reason}
Promise.racefirst settlesfirst rejectssingle value/reason
Promise.anyfirst fulfillsall 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 push method 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...of with await for true sequential dependencies; use Promise.all over .map() when tasks are independent.
  • Never combine await with Array.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.allSettled when 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.
Last updated June 14, 2026
Was this helpful?