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
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.
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
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.
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.
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.
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). Checkerrbefore anything else. - A
tryaround an async call cannot catch its error; thetryis over before the error exists. - Dependent steps nest. That is the problem promises solve.
- Use
node:fs/promisesand friends in new code;util.promisifyconverts 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
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:
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
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:
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
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
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.
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
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
nextTickqueues completely. Which of the two goes first differs between CommonJS and ES modules. setTimeout 0versussetImmediate: unordered at top level;setImmediatefirst 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
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
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
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
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
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.
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
| Use | When |
|---|---|
Promise.all | you need every result, and any failure means the whole thing failed |
Promise.allSettled | you want every outcome — send ten emails, report which failed |
Promise.race | the first to finish wins — usually a timeout, racing real work |
Promise.any | the first success wins — try three mirrors, use whichever answers |
A promise nobody handles
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
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 Promiseruns its executor immediately; mostly you use promises Node makes for you..thenreturns a new promise, so chains are flat. One.catchhandles a failure anywhere above it.- Start independent work together and
Promise.allit: three 100ms waits take 100ms, not 300. allSettledfor every outcome,racefor the first to finish,anyfor 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
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
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:
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
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
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:
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
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
asyncfunction returns a promise.awaitpauses the function and lets everything else run. - A
tryaround anawaitcatches its rejection. - Independent work: start it all, then
await Promise.all(...). Awaiting in a loop does it one at a time. forEachnever waits for an async callback. Usefor...oforPromise.all.- Limit how much runs at once when the list is long.
for awaitconsumes 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.
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.
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
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.
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
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.
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
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
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.
unhandledRejectionis for logging, not recovery. awaitonly catches its own promise's rejection; a throw inside a plain callback escapes.AbortControllercancels work: pass thesignal, callabort(), expect anAbortError.- A
Promise.racetimeout leaves its timer running.AbortSignal.timeoutdoes not. fetchhas no default timeout. Give every request a signal.- Retry transient failures with a limit and exponential backoff.