Emmanuel Akinfulubi

Asynchronous Node all 5 written

Callbacks, the event loop measured rather than drawn, promises, async/await, and what happens to an error nobody catches.

24 Callbacks and the error-first convention

Reading a file, querying a database or calling another server takes time — thousands of times longer than running a line of JavaScript. Node does not stop and wait. It starts the operation, carries on with the next line, and calls a function you gave it when the result is ready. That function is a callback, and it is the idea everything else in this part is built on.

The order things happen in

readFile does not wait

ran at build time

notes.txt

buy milk

example.mjs

import { readFile } from "node:fs";

const order = [];

readFile("notes.txt", "utf8", (err, text) => {
  order.push("3. the file arrived");
  console.log("callback:", JSON.stringify(text.trim()));
  check("the callback ran last", order, ["1. asked for the file", "2. carried on", "3. the file arrived"]);
});

order.push("1. asked for the file");
console.log("asked for the file");

order.push("2. carried on");
console.log("carried on with the next line");

What node printed, when the build ran it

node example.mjs

asked for the file
carried on with the next line
callback: "buy milk"
ok   the callback ran last

The line after readFile ran before the file's contents were printed. readFile asked the operating system for the file and returned immediately; the callback ran later, once the data had arrived and the rest of the file had finished running. This is non-blocking I/O, and it is why one Node process can serve thousands of users at once: while one request waits for the disk, the others are being handled.

Error first

Every callback-style Node function passes two things to your callback: an error (or null if it worked) and then the result. The error always comes first, so you cannot forget it is there.

Checking err before anything else

ran at build time

present.txt

here I am

example.mjs

import { readFile } from "node:fs";

const results = {};
const finished = [];

function load(name) {
  readFile(name, "utf8", (err, text) => {
    finished.push(name);
    if (err) {
      results[name] = err.code;
      console.log(`${name}: failed with ${err.code}`);
      return;                     // stop here -- there is no text
    }
    results[name] = text.trim();
    console.log(`${name}: ${text.trim()}`);
  });
}

load("present.txt");
load("missing.txt");

setTimeout(() => {
  check("one worked, one failed with a code", results, { "present.txt": "here I am", "missing.txt": "ENOENT" });
  check("and the one asked for second finished first", finished, ["missing.txt", "present.txt"]);
}, 50);

What node printed, when the build ran it

node example.mjs

missing.txt: failed with ENOENT
present.txt: here I am
ok   one worked, one failed with a code
ok   and the one asked for second finished first

Notice the order: the file that was asked for second finished first. Each callback runs when its own operation completes, and failing to find a file is quicker than reading one. (The last check holds this page to that claim: if a rebuild ever finished them the other way round, the build would stop.) Never assume two pieces of async work finish in the order you started them.

try cannot catch it

An async error, and a try around it

ran at build time

example.mjs

import { readFile } from "node:fs";

let caughtByTry = false;

try {
  readFile("missing.txt", "utf8", (err) => {
    console.log("the callback got:", err.code);
  });
} catch {
  caughtByTry = true;
}

console.log("try/catch caught something?", caughtByTry);
check("the try finished before the error existed", caughtByTry, false);

What node printed, when the build ran it

node example.mjs

try/catch caught something? false
ok   the try finished before the error existed
the callback got: ENOENT

This is the reason for the convention. By the time the file system reports the problem, the try block has long since finished, so there is nothing to catch it. The only place the error can be delivered is the callback, so it is delivered as an argument.

Ignoring err

dies with TypeError

example.mjs

import { readFile } from "node:fs";

readFile("missing.txt", "utf8", (err, text) => {
  console.log(text.toUpperCase());          // err was never checked
});

What node printed as it died, when the build ran it

node example.mjs

file:///app/example.mjs:4
  console.log(text.toUpperCase());          // err was never checked
                   ^

TypeError: Cannot read properties of undefined (reading 'toUpperCase')
    at ReadFileContext.callback (file:///app/example.mjs:4:20)
    at FSReqCallback.readFileAfterOpen [as oncomplete] (node:fs:343:13)

Node.js v26.7.0

Forget to check err and the next line uses a text that is undefined. The crash is a TypeError about undefined, a long way from the real problem — a missing file — which is why checking err first, every time, is not optional.

One thing after another

When each step needs the result of the one before, callbacks nest. Three steps is already hard to read, and error handling repeats at every level.

Nested callbacks

ran at build time

price.txt

10

rate.txt

0.2

example.mjs

import { readFile, writeFile } from "node:fs";

readFile("price.txt", "utf8", (err, price) => {
  if (err) return console.log("read failed", err.code);
  readFile("rate.txt", "utf8", (err, rate) => {
    if (err) return console.log("read failed", err.code);
    const total = (Number(price) * (1 + Number(rate))).toFixed(2);
    writeFile("total.txt", total, (err) => {
      if (err) return console.log("write failed", err.code);
      readFile("total.txt", "utf8", (err, saved) => {
        console.log("saved total:", saved);
        check("four dependent steps, four levels deep", saved, "12.00");
      });
    });
  });
});

What node printed, when the build ran it

node example.mjs

saved total: 12.00
ok   four dependent steps, four levels deep

That shape is known as callback hell, and it is what promises and async/await were invented to replace. Lesson 27 rewrites this exact program as four flat lines.

From callbacks to promises

Every callback API in Node also exists in a promise version, and any function that follows the error-first convention can be converted with util.promisify.

node:fs/promises, and promisify

ran at build time

notes.txt

buy milk

example.mjs

import { readFile } from "node:fs";
import { readFile as readFileP } from "node:fs/promises";
import { promisify } from "node:util";

const readFileQ = promisify(readFile);

const a = (await readFileP("notes.txt", "utf8")).trim();    // the built-in promise version
const b = (await readFileQ("notes.txt", "utf8")).trim();    // made from the callback version

console.log(a, "|", b);

check("both give the same text", a, b);

What node printed, when the build ran it

node example.mjs

buy milk | buy milk
ok   both give the same text

New code should use the promise versions — node:fs/promises, node:timers/promises, node:stream/promises. Callbacks are still worth understanding, because they are what the promise versions are built on, because event handlers (lesson 33) are still callbacks, and because you will read a great deal of code written before promises existed.

  • Node starts slow operations and carries on. The callback runs when the result is ready, after the current code finishes.
  • Callbacks take the error first: (err, result). Check err before anything else.
  • A try around an async call cannot catch its error; the try is over before the error exists.
  • Dependent steps nest. That is the problem promises solve.
  • Use node:fs/promises and friends in new code; util.promisify converts the rest.

25 The event loop, measured

Node runs your JavaScript on one thread. The event loop is how that one thread gets through thousands of pending operations: it runs your code to the end, then repeatedly picks up whatever has finished — a timer, a file read, an incoming request — and runs its callback. Most explanations draw a diagram. This lesson measures it instead, because several of the diagrams are wrong.

Your code always finishes first

Nothing interrupts running code

ran at build time

example.mjs

const order = [];

setTimeout(() => order.push("timer (0 ms)"), 0);
Promise.resolve().then(() => order.push("promise callback"));
order.push("synchronous code, line 1");
order.push("synchronous code, line 2");

setTimeout(() => {
  console.log(order);
  check("all synchronous code ran before any callback",
        order.slice(0, 2), ["synchronous code, line 1", "synchronous code, line 2"]);
}, 20);

What node printed, when the build ran it

node example.mjs

[
  'synchronous code, line 1',
  'synchronous code, line 2',
  'promise callback',
  'timer (0 ms)'
]
ok   all synchronous code ran before any callback

A callback never interrupts running JavaScript. Even a timer set for 0 milliseconds waits until the current code — here, the rest of the file — has completely finished. That single rule explains most of what surprises people about async code, and it is why lesson 43 is about the cost of code that takes a long time to finish.

Two queues before the loop moves on

Between each step, Node drains two small queues completely: microtasks (promise callbacks and queueMicrotask) and Node's own process.nextTick queue. Only then does it move on to timers and I/O. Here is the order everything ran in, in an ES module:

The order, in a .mjs file

ran at build time

example.mjs

const order = [];
const log = (what) => order.push(what);

setTimeout(() => log("setTimeout 0"), 0);
setImmediate(() => log("setImmediate"));
process.nextTick(() => log("process.nextTick"));
Promise.resolve().then(() => log("promise .then"));
queueMicrotask(() => log("queueMicrotask"));
log("synchronous");

setTimeout(() => {
  order.forEach((what, i) => console.log(i + 1, what));
  check("in an ES module, promise callbacks ran before nextTick",
        order.slice(0, 4), ["synchronous", "promise .then", "queueMicrotask", "process.nextTick"]);
}, 20);

What node printed, when the build ran it

node example.mjs

1 synchronous
2 promise .then
3 queueMicrotask
4 process.nextTick
5 setTimeout 0
6 setImmediate
ok   in an ES module, promise callbacks ran before nextTick

The identical code, in a .cjs file

ran at build time

example.cjs

const order = [];
const log = (what) => order.push(what);

setTimeout(() => log("setTimeout 0"), 0);
setImmediate(() => log("setImmediate"));
process.nextTick(() => log("process.nextTick"));
Promise.resolve().then(() => log("promise .then"));
queueMicrotask(() => log("queueMicrotask"));
log("synchronous");

setTimeout(() => {
  order.forEach((what, i) => console.log(i + 1, what));
  check("in CommonJS, nextTick ran before promise callbacks",
        order.slice(0, 4), ["synchronous", "process.nextTick", "promise .then", "queueMicrotask"]);
}, 20);

What node printed, when the build ran it

node example.cjs

1 synchronous
2 process.nextTick
3 promise .then
4 queueMicrotask
5 setImmediate
6 setTimeout 0
ok   in CommonJS, nextTick ran before promise callbacks

Look at positions 2 to 4. Almost every explanation of the event loop says process.nextTick runs before promise callbacks — and in the CommonJS file it does. In the ES module, the same code ran the promise callbacks first. The difference is where your top-level code runs from: an ES module's body is itself executed as part of a promise job, so by the time nextTick's callback is queued, the promise queue is already being worked through. The rule people repeat was written for CommonJS, and in modern modules it is backwards.

Do not write code that depends on this order. It changed between module systems, as you just saw, and it is an implementation detail of how Node schedules work. If one thing must happen after another, make it wait explicitly — with await, or by starting it inside the other's callback.

setTimeout 0 or setImmediate?

Positions 5 and 6 above were the two macrotask callbacks: the 0ms timer and setImmediate. At the top level of a file their order is not guaranteed — it depends on how long the process took to start, to the millisecond — so no panel here asserts it. Inside an I/O callback, it is guaranteed:

Inside an I/O callback, the order is fixed

ran at build time

example.mjs

import { readFile } from "node:fs";

readFile(import.meta.filename, () => {
  const order = [];
  setTimeout(() => order.push("setTimeout 0"), 0);
  setImmediate(() => order.push("setImmediate"));

  setTimeout(() => {
    console.log(order);
    check("after I/O, setImmediate always runs first", order, ["setImmediate", "setTimeout 0"]);
  }, 20);
});

What node printed, when the build ran it

node example.mjs

[ 'setImmediate', 'setTimeout 0' ]
ok   after I/O, setImmediate always runs first

That is the loop's structure showing through. It goes round in phases: timers, then waiting for I/O, then the "check" phase where setImmediate callbacks run, then back to timers. A file-read callback runs in the I/O phase, so the next phase is always "check", and the immediate runs before the loop comes back round to timers.

A timer's delay is a minimum

setTimeout promises "no sooner than"

ran at build time

example.mjs

let t = performance.now();
setTimeout(() => {
  const waited = performance.now() - t;
  console.log(`asked for 50 ms, waited ${waited.toFixed(1)} ms`);

  // Now keep the thread busy for 200 ms, and ask for 10.
  t = performance.now();
  setTimeout(() => {
    const late = performance.now() - t;
    console.log(`asked for 10 ms, waited ${late.toFixed(0)} ms, behind 200 ms of busy code`);
    check("a timer never fires early", waited >= 49, true);
    check("and fires late -- here, 20 times late -- if the thread is busy", late >= 190, true);
  }, 10);
  const until = performance.now() + 200;
  while (performance.now() < until) {}   // busy: nothing else can run
}, 50);

What node printed, when the build ran it

node example.mjs

asked for 50 ms, waited 50.4 ms
asked for 10 ms, waited 200 ms, behind 200 ms of busy code
ok   a timer never fires early
ok   and fires late -- here, 20 times late -- if the thread is busy

Microtasks can starve everything else

Two million microtasks in front of a timer

ran at build time

example.mjs

const t = performance.now();
let timerAt;
setTimeout(() => {
  timerAt = performance.now() - t;
  console.log(`the 0 ms timer fired after ${timerAt.toFixed(0)} ms`);
  check("it waited for every queued microtask first", timerAt > microtasksAt, true);
}, 0);

let microtasksAt;
let n = 0;
function again() {
  if (++n < 2_000_000) queueMicrotask(again);   // each one queues the next
  else microtasksAt = performance.now() - t;
}
queueMicrotask(again);

What node printed, when the build ran it

node example.mjs

the 0 ms timer fired after 100 ms
ok   it waited for every queued microtask first

The microtask queue is drained completely before the loop moves on — including microtasks added while it is being drained. Code that keeps queuing more (a promise chain that never ends, a recursive process.nextTick) holds up every timer, every incoming request and every file read for as long as it keeps going.

Where the waiting actually happens

If JavaScript runs on one thread, who reads the file while your code carries on? For network sockets, the operating system itself watches them. For most file-system operations, DNS lookups and some crypto functions, libuv keeps a small thread pool — four threads by default — and runs the work there.

Eight slow hashes, four threads

ran at build time

example.mjs

import { pbkdf2 } from "node:crypto";

const size = process.env.UV_THREADPOOL_SIZE ?? "4 (the default)";
const start = performance.now();
const finished = [];

for (let i = 0; i < 8; i++) {
  pbkdf2("password", "salt", 200_000, 64, "sha512", () => {
    finished.push(Math.round(performance.now() - start));
    if (finished.length === 8) {
      console.log("thread pool size:", size);
      console.log("each hash finished at (ms):", finished.join(" "));
      check("with 4 threads, the 8 hashes finished in two waves", finished[4] > finished[3] * 1.5, true);
    }
  });
}

What node printed, when the build ran it

node example.mjs

thread pool size: 4 (the default)
each hash finished at (ms): 144 145 147 148 313 314 316 317
ok   with 4 threads, the 8 hashes finished in two waves

The same, with UV_THREADPOOL_SIZE=8

ran at build time

example.mjs

import { pbkdf2 } from "node:crypto";

const size = process.env.UV_THREADPOOL_SIZE;
const start = performance.now();
const finished = [];

for (let i = 0; i < 8; i++) {
  pbkdf2("password", "salt", 200_000, 64, "sha512", () => {
    finished.push(Math.round(performance.now() - start));
    if (finished.length === 8) {
      console.log("thread pool size:", size);
      console.log("each hash finished at (ms):", finished.join(" "));
      check("with 8 threads, they finished as one group", finished[7] < finished[0] * 1.4, true);
    }
  });
}

What node printed, when the build ran it

UV_THREADPOOL_SIZE=8 node example.mjs

thread pool size: 8
each hash finished at (ms): 164 165 168 169 170 172 172 179
ok   with 8 threads, they finished as one group

With four threads, the first four hashes finished together and the next four finished roughly as long again afterwards: two waves, because only four could run at once. With eight threads, all eight ran together. The JavaScript thread was free the whole time; it was the pool that was full. A server that does a lot of file or crypto work can queue behind its own thread pool without the event loop itself looking busy at all.

  • Running code is never interrupted. Every callback waits for the current code to finish.
  • After each callback, Node drains the microtask and nextTick queues completely. Which of the two goes first differs between CommonJS and ES modules.
  • setTimeout 0 versus setImmediate: unordered at top level; setImmediate first after I/O.
  • A timer's delay is a minimum. Busy code and endless microtasks both delay it.
  • File, DNS and some crypto work runs on libuv's thread pool, four threads by default (UV_THREADPOOL_SIZE).

26 Promises

A promise is an object that stands in for a result that is not ready yet. Instead of passing a callback in, a function hands a promise back, and you attach what should happen next. That one change flattens the nesting from lesson 24, and makes errors travel the way they do in ordinary code.

Three states

pending, fulfilled, rejected

ran at build time

example.mjs

import { setTimeout as sleep } from "node:timers/promises";

const later = sleep(50, "the value");         // resolves after 50 ms
const now = Promise.resolve(42);             // already fulfilled
const broken = Promise.reject(new Error("no"));
broken.catch(() => {});                       // handled, so it does not crash the program

console.log(later);
console.log(now);
console.log(broken);

await later;
console.log(later);

check("a promise starts pending and settles once", await later, "the value");

What node printed, when the build ran it

node example.mjs

Promise { <pending> }
Promise { 42 }
Promise {
  <rejected> Error: no
      at file:///app/example.mjs:5:31
      at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
      at async node:internal/modules/esm/loader:650:26
      at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)
}
Promise { 'the value' }
ok   a promise starts pending and settles once

A promise is pending until it settles, exactly once, as either fulfilled with a value or rejected with a reason. After that it never changes. Node's console.log shows you which state a promise is in, which is useful when debugging — and await (next lesson) is how you wait for it.

Making one

new Promise(resolve, reject)

ran at build time

example.mjs

function wait(ms, value) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(value), ms);
  });
}

function mustBePositive(n) {
  return new Promise((resolve, reject) => {
    if (n > 0) resolve(n);
    else reject(new RangeError(`${n} is not positive`));
  });
}

console.log(await wait(20, "done waiting"));
console.log(await mustBePositive(5));
try {
  await mustBePositive(-1);
} catch (err) {
  console.log("rejected:", err.message);
}

check("resolve fulfils it with a value", await wait(1, "x"), "x");

What node printed, when the build ran it

node example.mjs

done waiting
5
rejected: -1 is not positive
ok   resolve fulfils it with a value

The executor is not deferred

ran at build time

example.mjs

const steps = [];

const p = new Promise((resolve) => {
  steps.push("the executor runs immediately");
  resolve("first");
  resolve("second");              // ignored: a promise settles only once
  steps.push("and keeps running after resolve");
});
steps.push("then the next line");

console.log(steps, await p);

check("the executor ran before the next line", steps[0], "the executor runs immediately");
check("only the first resolve counts", await p, "first");

What node printed, when the build ran it

node example.mjs

[
  'the executor runs immediately',
  'and keeps running after resolve',
  'then the next line'
] first
ok   the executor ran before the next line
ok   only the first resolve counts

The function you pass to new Promise runs straight away, on the spot. Only the callbacks you attach later wait. You will mostly use promises rather than make them — every promise-based Node API makes them for you — but wrapping an old callback API is the classic reason to write new Promise yourself.

then, catch, finally

A chain, and one place for errors

ran at build time

price.txt

10

example.mjs

import { readFile } from "node:fs/promises";

const log = [];

const total = await readFile("price.txt", "utf8")
  .then((text) => Number(text))                  // each then gets the previous result
  .then((price) => price * 1.2)
  .then((withTax) => withTax.toFixed(2))
  .catch((err) => { log.push(`failed: ${err.code}`); return "0.00"; })
  .finally(() => log.push("finally: runs either way"));

const missing = await readFile("nope.txt", "utf8")
  .then((text) => Number(text))
  .catch((err) => { log.push(`failed: ${err.code}`); return 0; });

console.log(total, missing, log);

check("the chain passed each result to the next step", total, "12.00");
check("one catch handles a failure anywhere above it", missing, 0);

What node printed, when the build ran it

node example.mjs

12.00 0 [ 'finally: runs either way', 'failed: ENOENT' ]
ok   the chain passed each result to the next step
ok   one catch handles a failure anywhere above it

Each .then returns a new promise for whatever its function returns, which is what lets them chain in a flat line. And a rejection anywhere in the chain skips every .then until it reaches a .catch — the same way a thrown error skips lines until it reaches a catch block.

Several at once

Promise.all, timed

ran at build time

example.mjs

import { setTimeout as sleep } from "node:timers/promises";

const fetchUser = () => sleep(100, { name: "Ada" });
const fetchOrders = () => sleep(100, [101, 102]);
const fetchPrefs = () => sleep(100, { theme: "dark" });

let t = performance.now();
const user = await fetchUser();
const orders = await fetchOrders();
const prefs = await fetchPrefs();
const oneByOne = performance.now() - t;

t = performance.now();
const [u, o, p] = await Promise.all([fetchUser(), fetchOrders(), fetchPrefs()]);
const together = performance.now() - t;

console.log(`one after another: ${oneByOne.toFixed(0)} ms`);
console.log(`Promise.all:       ${together.toFixed(0)} ms`);

check("the same results either way", [u, o, p], [user, orders, prefs]);
check("but all three waited at the same time", together < oneByOne / 2, true);

What node printed, when the build ran it

node example.mjs

one after another: 306 ms
Promise.all:       100 ms
ok   the same results either way
ok   but all three waited at the same time

Three independent 100ms operations took about 300ms one after another and about 100ms together. Promise.all takes an array of promises and gives back one promise for an array of their results, in the same order. Because the three operations were all started before any was waited for, their waiting overlapped.

all, allSettled, race, any

ran at build time

example.mjs

import { setTimeout as sleep } from "node:timers/promises";

const ok = (ms, v) => sleep(ms, v);
const fail = async (ms, why) => { await sleep(ms); throw new Error(why); };

// all: every one, or the first failure.
const all = await Promise.all([ok(10, "a"), fail(20, "b broke"), ok(30, "c")]).catch((e) => e.message);

// allSettled: every outcome, success or failure.
const settled = await Promise.allSettled([ok(10, "a"), fail(20, "b broke")]);

// race: whichever settles first, success or failure.
const race = await Promise.race([ok(30, "slow"), ok(10, "fast")]);

// any: the first success; only fails if every one fails.
const any = await Promise.any([fail(10, "x"), ok(20, "first success")]);
const none = await Promise.any([fail(10, "x"), fail(20, "y")]).catch((e) => e.constructor.name);

console.log({ all, race, any, none });
console.log(settled);

check("all rejects with the first failure", all, "b broke");
check("allSettled reports each outcome", settled.map((s) => s.status), ["fulfilled", "rejected"]);
check("race takes the fastest", race, "fast");
check("any ignores failures until one succeeds", any, "first success");
check("and rejects with an AggregateError if all fail", none, "AggregateError");

What node printed, when the build ran it

node example.mjs

{
  all: 'b broke',
  race: 'fast',
  any: 'first success',
  none: 'AggregateError'
}
[
  { status: 'fulfilled', value: 'a' },
  {
    status: 'rejected',
    reason: Error: b broke
        at fail (file:///app/example.mjs:4:58)
        at async Promise.allSettled (index 1)
        at async file:///app/example.mjs:10:17
  }
]
ok   all rejects with the first failure
ok   allSettled reports each outcome
ok   race takes the fastest
ok   any ignores failures until one succeeds
ok   and rejects with an AggregateError if all fail
UseWhen
Promise.allyou need every result, and any failure means the whole thing failed
Promise.allSettledyou want every outcome — send ten emails, report which failed
Promise.racethe first to finish wins — usually a timeout, racing real work
Promise.anythe first success wins — try three mirrors, use whichever answers

A promise nobody handles

An unhandled rejection ends the process

dies with Error

example.mjs

import { setTimeout as sleep } from "node:timers/promises";

async function charge(amount) {
  await sleep(10);
  throw new Error(`card declined for ${amount}`);
}

charge(25);           // started, and never awaited or caught
console.log("carried on as if it worked");

What node printed as it died, when the build ran it

node example.mjs

carried on as if it worked
file:///app/example.mjs:5
  throw new Error(`card declined for ${amount}`);
        ^

Error: card declined for 25
    at charge (file:///app/example.mjs:5:9)

Node.js v26.7.0

A rejected promise with nothing attached to handle it is treated as a crash: Node prints the error and exits with code 1. The program did not stop at the call — "carried on as if it worked" printed — so the failure surfaces somewhere else, later. Every promise you start must be awaited, returned, or given a .catch. Lesson 28 is about doing that well.

Promise.withResolvers

Resolving from outside

ran at build time

example.mjs

const { promise, resolve } = Promise.withResolvers();

// Hand resolve to something else -- here, an event that happens later.
setTimeout(() => resolve("the event happened"), 20);

console.log(await promise);

check("resolve can be called from outside the executor", await promise, "the event happened");

What node printed, when the build ran it

node example.mjs

the event happened
ok   resolve can be called from outside the executor

Sometimes the code that should settle a promise is not the code that creates it — "resolve this when the server says it is ready". Promise.withResolvers() hands you the promise and its resolve and reject functions together, instead of making you smuggle them out of a new Promise executor.

  • A promise is pending until it settles, once, as fulfilled or rejected.
  • new Promise runs its executor immediately; mostly you use promises Node makes for you.
  • .then returns a new promise, so chains are flat. One .catch handles a failure anywhere above it.
  • Start independent work together and Promise.all it: three 100ms waits take 100ms, not 300.
  • allSettled for every outcome, race for the first to finish, any for the first success.
  • An unhandled rejection kills the process. Await, return, or catch every promise.

27 async and await

async and await let you write code that waits on promises as if it were ordinary step-by-step code. They do not add anything promises cannot do — they make the common case readable. Most Node code written today looks like this lesson.

await pauses the function, not the program

Waiting without blocking

ran at build time

example.mjs

import { setTimeout as sleep } from "node:timers/promises";

const log = [];

async function makeTea() {
  log.push("tea: kettle on");
  await sleep(50);                  // this function pauses here...
  log.push("tea: poured");
  return "tea";
}

const ticker = setInterval(() => log.push("...other work carries on"), 15);

const drink = await makeTea();       // ...but the rest of the program does not
clearInterval(ticker);

console.log(log, drink);

check("an async function returns a promise", makeTea() instanceof Promise, true);
check("other callbacks ran while it was waiting", log.filter((l) => l.startsWith("...")).length >= 2, true);

What node printed, when the build ran it

node example.mjs

[
  'tea: kettle on',
  '...other work carries on',
  '...other work carries on',
  '...other work carries on',
  'tea: poured'
] tea
ok   an async function returns a promise
ok   other callbacks ran while it was waiting

An async function always returns a promise. Inside it, await pauses that function until the promise settles, and hands the thread back to the event loop in the meantime — which is why the interval kept ticking. When the promise fulfils, the function resumes on the next line with the value.

Lesson 24's program, flat

Four dependent steps, four lines

ran at build time

price.txt

10

rate.txt

0.2

example.mjs

import { readFile, writeFile } from "node:fs/promises";

const price = Number(await readFile("price.txt", "utf8"));
const rate = Number(await readFile("rate.txt", "utf8"));
await writeFile("total.txt", (price * (1 + rate)).toFixed(2));
const saved = await readFile("total.txt", "utf8");

console.log("saved total:", saved);

check("the same four steps, with no nesting", saved, "12.00");

What node printed, when the build ran it

node example.mjs

saved total: 12.00
ok   the same four steps, with no nesting

Those are the same four operations as the nested callbacks in lesson 24, in the same order, each waiting for the one before. And errors work the way you already know from lesson 18:

try and catch, around await

ran at build time

broken.json

{ not json

example.mjs

import { readFile } from "node:fs/promises";

async function loadConfig(path) {
  try {
    return JSON.parse(await readFile(path, "utf8"));
  } catch (err) {
    if (err.code === "ENOENT") return { defaults: true };
    throw new Error(`bad config in ${path}`, { cause: err });
  }
}

console.log(await loadConfig("missing.json"));
try {
  await loadConfig("broken.json");
} catch (err) {
  console.log(err.message, "<- caused by", err.cause.name);
}

check("try/catch catches an awaited rejection", await loadConfig("missing.json"), { defaults: true });

What node printed, when the build ran it

node example.mjs

{ defaults: true }
bad config in broken.json <- caused by SyntaxError
ok   try/catch catches an awaited rejection

Unlike the callback version in lesson 24, a try around an await does catch the failure, because the function is paused inside the try until the promise settles. That is the single biggest improvement await brings.

Mistake one: waiting in a loop

Sequential versus concurrent, timed

ran at build time

example.mjs

import { setTimeout as sleep } from "node:timers/promises";

const fetchPrice = (id) => sleep(100, id * 10);      // stands in for a request
const ids = [1, 2, 3, 4, 5];

let t = performance.now();
const oneAtATime = [];
for (const id of ids) {
  oneAtATime.push(await fetchPrice(id));             // waits for each before starting the next
}
const slow = performance.now() - t;

t = performance.now();
const together = await Promise.all(ids.map((id) => fetchPrice(id)));   // start all, then wait
const fast = performance.now() - t;

console.log(`await in a loop: ${slow.toFixed(0)} ms`);
console.log(`Promise.all:     ${fast.toFixed(0)} ms`);

check("same answers", together, oneAtATime);
check("the loop waited five times in a row", slow > 450, true);
check("Promise.all waited once", fast < 250, true);

What node printed, when the build ran it

node example.mjs

await in a loop: 502 ms
Promise.all:     100 ms
ok   same answers
ok   the loop waited five times in a row
ok   Promise.all waited once

An await inside a for loop waits for each item before starting the next. Sometimes that is exactly right — each step needs the last one's result, or you must not hammer a server. When the items are independent, start them all with map and wait once with Promise.all.

Mistake two: forEach does not wait

forEach with an async callback

ran at build time

example.mjs

import { setTimeout as sleep } from "node:timers/promises";

const orders = [{ total: 10 }, { total: 20 }, { total: 30 }];
let revenue = 0;

orders.forEach(async (order) => {
  await sleep(10);                  // e.g. look up a discount
  revenue += order.total;
});

const straightAfter = revenue;
console.log("revenue straight after forEach:", straightAfter);
await sleep(50);
console.log("revenue a moment later:        ", revenue);

check("forEach returned before any callback finished", straightAfter, 0);
check("the callbacks did run, eventually", revenue, 60);

What node printed, when the build ran it

node example.mjs

revenue straight after forEach: 0
revenue a moment later:         60
ok   forEach returned before any callback finished
ok   the callbacks did run, eventually

forEach calls the async function for each item, gets a promise back each time, and throws the promises away. It does not know they are promises, so nothing waits, and the line after forEach sees a total of 0. Any rejection from those callbacks is unhandled, too. Use for...of with await, or Promise.all with map.

Mistake three: starting too many at once

Promise.all on ten thousand items starts ten thousand operations at once — ten thousand open files, or ten thousand requests to one server. Real code limits how many are in flight. It takes a few lines:

A concurrency limit

ran at build time

example.mjs

import { setTimeout as sleep } from "node:timers/promises";

async function mapLimit(items, limit, fn) {
  const results = new Array(items.length);
  let next = 0;
  async function worker() {
    while (next < items.length) {
      const i = next++;
      results[i] = await fn(items[i]);
    }
  }
  await Promise.all(Array.from({ length: limit }, worker));
  return results;
}

let inFlight = 0;
let peak = 0;
const job = async (n) => {
  inFlight++;
  peak = Math.max(peak, inFlight);
  await sleep(20);
  inFlight--;
  return n * n;
};

const squares = await mapLimit([1, 2, 3, 4, 5, 6, 7, 8], 2, job);
console.log(squares, "peak in flight:", peak);

check("every result, in order", squares, [1, 4, 9, 16, 25, 36, 49, 64]);
check("never more than two at a time", peak, 2);

What node printed, when the build ran it

node example.mjs

[
   1,  4,  9, 16,
  25, 36, 49, 64
] peak in flight: 2
ok   every result, in order
ok   never more than two at a time

for await, for things that arrive over time

An async generator

ran at build time

example.mjs

import { setTimeout as sleep } from "node:timers/promises";

async function* ticks(count) {
  for (let i = 1; i <= count; i++) {
    await sleep(10);
    yield `tick ${i}`;
  }
}

const seen = [];
for await (const tick of ticks(3)) {
  seen.push(tick);
}

console.log(seen);

check("for await takes each value as it arrives", seen, ["tick 1", "tick 2", "tick 3"]);

What node printed, when the build ran it

node example.mjs

[ 'tick 1', 'tick 2', 'tick 3' ]
ok   for await takes each value as it arrives

An async function* is lesson 19's generator with await allowed inside, and for await is the loop for it. This matters beyond this example because many Node objects are async-iterable: a file read line by line, a stream of HTTP chunks, the rows of a database query. Lesson 35 reads a large file exactly this way.

  • An async function returns a promise. await pauses the function and lets everything else run.
  • A try around an await catches its rejection.
  • Independent work: start it all, then await Promise.all(...). Awaiting in a loop does it one at a time.
  • forEach never waits for an async callback. Use for...of or Promise.all.
  • Limit how much runs at once when the list is long.
  • for await consumes anything async-iterable: generators, streams, lines of a file.

28 Async errors, cancellation and timeouts

Async code fails in ways synchronous code cannot: a failure nobody is waiting to hear about, an operation that should be stopped but cannot be, and a wait that never ends. This lesson covers the three, and the tools Node gives you for each.

Every promise needs an owner

Lesson 26 showed that an unhandled rejection ends the process. The fix is not a global handler; it is making sure every promise you start is either awaited, returned to a caller who awaits it, or given a .catch.

Awaited, or caught

ran at build time

example.mjs

import { setTimeout as sleep } from "node:timers/promises";

async function sendEmail(to) {
  await sleep(5);
  if (to.endsWith("@invalid")) throw new Error(`cannot deliver to ${to}`);
  return `sent to ${to}`;
}

// Fire-and-forget, done properly: we do not wait, but we do own the failure.
const failures = [];
sendEmail("someone@invalid").catch((err) => failures.push(err.message));

// Awaited: the failure arrives here, where we can decide what it means.
const results = await Promise.allSettled(["ada@example.com", "bob@invalid"].map(sendEmail));

await sleep(20);
console.log(results.map((r) => r.status), failures);

check("the awaited batch reported each outcome", results.map((r) => r.status), ["fulfilled", "rejected"]);
check("the fire-and-forget failure was caught, not crashed on", failures, ["cannot deliver to someone@invalid"]);

What node printed, when the build ran it

node example.mjs

[ 'fulfilled', 'rejected' ] [ 'cannot deliver to someone@invalid' ]
ok   the awaited batch reported each outcome
ok   the fire-and-forget failure was caught, not crashed on

Node does give you a last resort. process.on("unhandledRejection") is called for any rejection nobody handled, and is the right place to log one before exiting. It is the wrong place to recover: by the time it runs, the code that should have handled the failure has already moved on as if it succeeded.

A last-resort handler

ran at build time

example.mjs

process.on("unhandledRejection", (reason) => {
  console.error("unhandled:", reason.message);
  process.exitCode = 1;            // still fail -- just with a message we chose
});

Promise.reject(new Error("the payment job failed"));

setTimeout(() => {
  console.log("exit code will be", process.exitCode);
  check("the handler ran and marked the process as failed", process.exitCode, 1);
}, 10);

What node printed, when the build ran it

node example.mjs

unhandled: the payment job failed
exit code will be 1
ok   the handler ran and marked the process as failed

Do not turn the crash off. node --unhandled-rejections=warn makes Node print a warning and carry on, which was the default in very old versions. A program that keeps running after an operation silently failed is worse than one that stops: it goes on to report success, charge twice, or write half a file.

Errors thrown in a callback escape the try

A throw the await cannot see

dies with Error

example.mjs

import { setTimeout as sleep } from "node:timers/promises";

async function job() {
  setTimeout(() => {
    throw new Error("thrown inside a timer callback");
  }, 5);
  await sleep(20);
  return "finished";
}

try {
  console.log(await job());
} catch (err) {
  console.log("caught:", err.message);      // never reached
}

What node printed as it died, when the build ran it

node example.mjs

file:///app/example.mjs:5
    throw new Error("thrown inside a timer callback");
    ^

Error: thrown inside a timer callback
    at Timeout._onTimeout (file:///app/example.mjs:5:11)
    at listOnTimeout (node:internal/timers:685:17)
    at process.processTimers (node:internal/timers:618:7)

Node.js v26.7.0

await catches rejections of the promise it is waiting for — nothing else. An error thrown inside a plain callback, such as a timer's, is not part of that promise, so it escapes every try and becomes an uncaught exception. Mixing callbacks into async code is where these come from; the fix is to use the promise version of the API throughout.

Cancelling: AbortController

Node's way of stopping something already started is the AbortController. You make one, pass its signal to the operation, and call abort() when you want it to stop. Timers, fetch, file reads, streams and child processes all accept a signal.

Cancelling a wait

ran at build time

example.mjs

import { setTimeout as sleep } from "node:timers/promises";

const controller = new AbortController();

// Some time later, something decides the work is no longer wanted.
setTimeout(() => controller.abort(new Error("user closed the page")), 20);

const started = performance.now();
let outcome;
try {
  await sleep(10_000, null, { signal: controller.signal });
  outcome = "finished";
} catch (err) {
  outcome = `${err.name} after ${Math.round(performance.now() - started)} ms, because: ${err.cause.message}`;
}

console.log(outcome);

check("the 10-second wait was cut short", outcome.startsWith("AbortError"), true);
check("and the reason travelled with it", controller.signal.reason.message, "user closed the page");

What node printed, when the build ran it

node example.mjs

AbortError after 21 ms, because: user closed the page
ok   the 10-second wait was cut short
ok   and the reason travelled with it

A cancelled operation rejects with an AbortError. That is an error you asked for, so the usual pattern is to check err.name === "AbortError" and treat it as a normal outcome rather than a failure.

Timeouts, and the one that leaks

Promise.race, with a timer that never stops

ran at build time

example.mjs

const started = Date.now();
process.on("exit", () => {
  const lifetime = Date.now() - started;
  console.log(`process lived for about ${Math.round(lifetime / 100) * 100} ms`);
  check("the process was kept alive by the losing timer", lifetime >= 1900, true);
});

const timeout = (ms) => new Promise((_, reject) => setTimeout(() => reject(new Error("timed out")), ms));
const work = new Promise((resolve) => setTimeout(() => resolve("work done in 10 ms"), 10));

console.log(await Promise.race([work, timeout(2000)]));

What node printed, when the build ran it

node example.mjs

work done in 10 ms
process lived for about 2000 ms
ok   the process was kept alive by the losing timer

The race was won in 10 milliseconds. The process then lived for two seconds, because the timeout's timer was still pending, and Node does not exit while a timer is pending (lesson 1). In a server that does this on every request, thousands of dead timers pile up.

AbortSignal.timeout

ran at build time

example.mjs

import { setTimeout as sleep } from "node:timers/promises";

const started = Date.now();
process.on("exit", () => {
  const lifetime = Date.now() - started;
  console.log(`process lived for about ${Math.round(lifetime / 10) * 10} ms`);
  check("nothing kept it alive afterwards", lifetime < 500, true);
});

const result = await sleep(10, "work done in 10 ms", { signal: AbortSignal.timeout(2000) });
console.log(result);

try {
  await sleep(5_000, null, { signal: AbortSignal.timeout(30) });
} catch (err) {
  console.log("a slow one:", err.name, "caused by", err.cause.name);
}

What node printed, when the build ran it

node example.mjs

work done in 10 ms
a slow one: AbortError caused by TimeoutError
process lived for about 50 ms
ok   nothing kept it alive afterwards

AbortSignal.timeout(ms) makes a signal that aborts itself after that long, and its timer does not keep the process alive. It works anywhere a signal does, and AbortSignal.any([...]) combines several — "stop if the user cancels or it takes longer than five seconds".

Timing out a request

A request that would have waited a second

ran at build time

example.mjs

import { createServer } from "node:http";

// A server that takes a second to answer.
const server = createServer((req, res) => setTimeout(() => res.end("finally"), 1000));
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const url = `http://127.0.0.1:${server.address().port}/`;

let outcome;
try {
  await fetch(url, { signal: AbortSignal.timeout(50) });
  outcome = "answered";
} catch (err) {
  outcome = err.name;
}

console.log("fetch with a 50 ms timeout:", outcome);
server.closeAllConnections();
server.close();

check("fetch gave up after 50 ms", outcome, "TimeoutError");

What node printed, when the build ran it

node example.mjs

fetch with a 50 ms timeout: TimeoutError
ok   fetch gave up after 50 ms

fetch has no timeout by default — a request to a server that never answers waits forever. Every fetch in real code should carry a signal. Note that fetch reports a timeout with the name TimeoutError, where the timer above reported AbortError with a TimeoutError cause: check both when you care which it was.

Trying again

Retry with exponential backoff

ran at build time

example.mjs

import { setTimeout as sleep } from "node:timers/promises";

async function withRetry(fn, { attempts = 4, baseMs = 10 } = {}) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await fn(attempt);
    } catch (err) {
      if (attempt === attempts) throw err;
      const wait = baseMs * 2 ** (attempt - 1);       // 10, 20, 40...
      console.log(`attempt ${attempt} failed (${err.message}); retrying in ${wait} ms`);
      await sleep(wait);
    }
  }
}

const flaky = async (attempt) => {
  if (attempt < 3) throw new Error("service unavailable");
  return `succeeded on attempt ${attempt}`;
};

const result = await withRetry(flaky);
console.log(result);

check("it succeeded on the third try", result, "succeeded on attempt 3");

What node printed, when the build ran it

node example.mjs

attempt 1 failed (service unavailable); retrying in 10 ms
attempt 2 failed (service unavailable); retrying in 20 ms
succeeded on attempt 3
ok   it succeeded on the third try

Retry only what might succeed next time — a timeout, a 503 — never a validation error, and always with a limit and a growing wait, so a struggling service is not hammered by everyone retrying at once.

  • Every promise must be awaited, returned, or caught. unhandledRejection is for logging, not recovery.
  • await only catches its own promise's rejection; a throw inside a plain callback escapes.
  • AbortController cancels work: pass the signal, call abort(), expect an AbortError.
  • A Promise.race timeout leaves its timer running. AbortSignal.timeout does not.
  • fetch has no default timeout. Give every request a signal.
  • Retry transient failures with a limit and exponential backoff.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.