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.allSettledwhen 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.promisifyconverts error-first callback APIs- Independent work:
Promise.all; dependent work: sequentialawait - 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.