Asynchronous Node

Turn old callbacks into promises, run work in the right order, and never block the loop.

06of 18
≈ 11Min read
7Parts

One thread, many things in flight

Your JavaScript runs on one thread. Disk reads, network calls and timers are handed to the operating system, and their results come back as callbacks the event loop runs when your code is idle. That is how one process serves thousands of connections without a thread each.

what "blocking" means in practice
// a request arrives every 10 ms; this handler takes 2 s of CPU
app.get("/report", (req, res) => {
  const rows = buildHugeReport();     // nothing else runs for two seconds
  res.json(rows);
});
// 200 waiting requests, all slow, and the health check times out too

The callback convention

Every callback-style API in Node takes (err, value). That consistency is what makes it mechanical to convert them.

error first, always
fs.readFile("doors.json", "utf8", (err, data) => {
  if (err) return console.error(err);     // the first argument is the error
  console.log(data);
});

Promisifying

by hand, and with the built-in
import { promisify } from "node:util";

const readFile = promisify(fs.readFile);
const data = await readFile("doors.json", "utf8");

// what it does, in six lines
function promisify(fn) {
  return (...args) => new Promise((resolve, reject) => {
    fn(...args, (err, value) => (err ? reject(err) : resolve(value)));
  });
}
Usually unnecessary now

Most built-ins ship a promise version already: fs/promises, dns/promises, stream/promises, timers/promises. Reach for promisify for older third-party packages.

Sequential or parallel

  • Promise.all — all of them, and the first failure rejects the whole thing.
  • Promise.allSettled — all of them, and you get every outcome: { status: "fulfilled" | "rejected" }.
  • Promise.race — the first to settle, good for a timeout.
  • Promise.any — the first to succeed.
await in a loop is a decision, not a default
// 3 × 200 ms = 600 ms — each waits for the one before
for (const id of ids) results.push(await load(id));

// 200 ms — all three start at once
const results = await Promise.all(ids.map(load));
Parallel is not always right

Firing 5 000 requests at a database at once is a denial-of-service attack on yourself. Batch them, or use a small concurrency limit.

Errors

An async function never throws to its caller synchronously — it returns a rejected promise. Forgetting await in front of a call inside a try means the catch is never reached.

where they go
try {
  const data = await load();
} catch (err) {
  if (err.code === "ENOENT") return null;
  throw err;                     // do not swallow what you cannot handle
}

// a rejected promise nobody awaited crashes the process (Node 15+)
process.on("unhandledRejection", (err) => { log.fatal(err); process.exit(1); });

Timing out anything

two ways, both worth knowing
const result = await Promise.race([
  load(),
  new Promise((_, reject) => setTimeout(() => reject(new Error("Timed out")), 2000)),
]);

// better where it is supported: actually cancel the work
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });

CPU work does not belong here

  • Hashing, image processing, big parsing, compression: all of it blocks the loop.
  • Move it to a worker thread (node:worker_threads), a child process, or a background queue.
  • await does not help — awaiting a synchronous calculation still runs it on this thread.
the giveaway
// this awaits nothing and blocks for a second
async function slow() {
  let x = 0;
  for (let i = 0; i < 1e9; i++) x += i;
  return x;
}

Practice