Async patterns in Node

Node.js · Practical · 14 min

Real services juggle many I/O operations; doing them in the right order, in parallel, and cancellably is the job.

Node's original convention is the error-first callback: the last argument is a function called as cb(err, result). You will still meet it in older libraries. Every built-in now has a promise form (node:fs/promises, node:timers/promises, node:dns/promises, node:stream/promises), and util.promisify(fn) converts any error-first function into one that returns a promise.

With promises, the main skill is choosing sequential vs concurrent:

  • await a(); await b(); — b starts only after a finishes. Right when b needs a's result.
  • await Promise.all([a(), b()]) — both run at once; fails fast on the first rejection.
  • Promise.allSettled when you want every outcome, even failures.

Unbounded concurrency is its own bug: Promise.all(10_000 urls.map(fetch)) opens ten thousand sockets. Process big lists in batches or with a small pool.

Cancellation in Node uses the web-standard AbortController. Pass signal to fetch, fs.readFile, setTimeout from timers/promises, events.once, child processes and streams; calling controller.abort() makes them reject with an AbortError. For deadlines, AbortSignal.timeout(ms) creates a signal that aborts by itself, and AbortSignal.any([a, b]) combines signals.

node:timers/promises gives awaitable timers: await setTimeout(500) sleeps, and for await (const _ of setInterval(1000)) ticks.

Finally, never leave a promise floating. A rejected promise nobody awaits becomes an unhandled rejection, which crashes Node by default. If you intentionally fire and forget, attach a .catch().

Example

import { promisify } from "node:util";
import { setTimeout as sleep } from "node:timers/promises";
import { readFile } from "node:fs/promises";
import { execFile } from "node:child_process";

const execFileAsync = promisify(execFile);          // callback API -> promise
const { stdout } = await execFileAsync("node", ["--version"]);
console.log(stdout.trim());

// Concurrent: both start immediately; total time ~ the slower one
const [pkg] = await Promise.all([
  readFile(new URL("./package.json", import.meta.url), "utf8"),
  sleep(100),
]);

// Bounded concurrency: process items in batches of 5
async function mapInBatches(items, size, fn) {
  const out = [];
  for (let i = 0; i < items.length; i += size) {
    out.push(...await Promise.all(items.slice(i, i + size).map(fn)));
  }
  return out;
}
console.log(await mapInBatches([1, 2, 3, 4, 5, 6], 5, async (n) => n * 2));

Remember

  • util.promisify converts error-first callback APIs
  • Independent work: Promise.all; dependent work: sequential await
  • Bound concurrency for big lists
  • Cancel with AbortController; deadlines with AbortSignal.timeout(ms)

Watch out for

  • Awaiting inside a for loop for independent requests, making them needlessly sequential.
  • Firing a promise without await or .catch, causing an unhandled rejection crash.

Check yourself

  1. You must call getUser(id) and getOrders(id); neither needs the other's result. What's best?
  2. How do you give a fetch call a 2-second deadline in Node 22?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: nodejs.org/learn/asynchronous-work/javascript-asynchronous-programming-and-callbacks