19 — Promises and async–await
Level: Intermediate · Time: ~30 min · Prerequisites: Lesson 18 — The Node.js Runtime and the Event Loop
Why this matters
Node does not wait: a file read, a database query and an HTTP call all finish later, and the result
must reach code that is no longer on the stack. The first answer was callbacks, and it fails in a
specific way — the continuation of your program is handed to somebody else's function, and every extra
step nests one level deeper. Promises and async/await simply read better, and nearly every bug in
this area is either awaiting work that could have run in parallel, or losing a rejection.
Click the diagram to open it full size.
Why callbacks are not enough
// src/callbacks.ts
import { readFile } from "node:fs";
readFile("config.json", "utf8", (configError, config) => {
if (configError) return console.error(configError);
readFile("plants.csv", "utf8", (plantsError, plants) => {
if (plantsError) return console.error(plantsError);
console.log(`${config.length} + ${plants.length} characters`);
});
});
Three problems. Inversion of control: readFile decides when your continuation runs, and how often.
The error-first convention: every callback must test its first argument, and the day someone
forgets, the failure becomes a silent undefined. Depth: one more file, one more indent.
A promise is a value that will exist later
A Promise<T> represents a T that is not ready yet: pending, then fulfilled with a value or
rejected with a reason, settling at most once. You consume it with then, catch and finally.
// src/promise-basics.ts
function parseDays(raw: string): Promise<number> {
return new Promise((resolve, reject) => {
const days = Number(raw);
if (!Number.isInteger(days) || days <= 0) {
reject(new RangeError(`--days must be a positive integer, got ${raw}`));
return;
}
resolve(days);
});
}
parseDays("30")
.then((days) => days * 24)
.then((hours) => console.log(`${hours} hours of production`))
.catch((error: unknown) => console.error("rejected:", error));
The executor passed to new Promise runs immediately and synchronously, so real work never belongs
in it; it is a wrapper around something that already provides a callback. Each link of a chain is a
new promise, which is what lets a rejection be handled. For several promises at once, Lesson 21 —
Concurrency covers Promise.all (rejects on the first rejection), Promise.allSettled (every outcome,
for honest partial success) and Promise.race (the first settle).
async/await is the same machine, written in order
An async function always returns a promise: return 3 fulfils it with 3, and a thrown error rejects
it. await pauses only that function, hands the thread back to the loop, and resumes when the promise
settles.
// src/tariff.ts
import { readFile } from "node:fs/promises";
export interface TariffBand {
readonly label: string;
readonly pricePerKwh: number;
}
export async function loadTariffs(path: string): Promise<TariffBand[]> {
const raw = await readFile(path, "utf8");
const rows = raw.trim().split("\n").slice(1); // drop the header row
return rows.map((row) => {
const [label, price] = row.split(",");
return { label, pricePerKwh: Number(price) };
});
}
Because loadTariffs is async, its caller receives a Promise<TariffBand[]>: from a plain function
you would need .then, from another async function one await. Top-level await works in an ES
module with (module: "nodenext" or "esnext"); under CommonJS the work belongs in a main().
Sequential versus parallel: the accidental serialisation trap
This is the mistake that costs real seconds. Awaiting inside a loop runs the calls one after another.
// src/pipeline.ts
import { writeFile } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";
interface Page {
readonly month: string;
readonly yieldKwh: number;
}
// Stands in for a network call: Lesson 27 replaces the body with a real fetch.
async function loadPage(month: string): Promise<Page> {
await sleep(300);
return { month, yieldKwh: 400 + month.length * 7 };
}
async function main(): Promise<void> {
const months = ["2024-01", "2024-02", "2024-03", "2024-04", "2024-05", "2024-06"];
const beforeSequential = performance.now();
for (const month of months) await loadPage(month); // 6 × 300 ms, one after another
const afterSequential = performance.now();
const pages = await Promise.all(months.map((month) => loadPage(month))); // about 300 ms
const afterParallel = performance.now();
console.log(`sequential ${Math.round(afterSequential - beforeSequential)} ms, parallel ${Math.round(afterParallel - afterSequential)} ms`);
console.log(`total yield: ${pages.reduce((sum, page) => sum + page.yieldKwh, 0)} kWh`);
await writeFile("solar-yield.json", JSON.stringify(pages, null, 2), "utf8");
}
main().catch((error: unknown) => {
console.error("pipeline failed:", error);
process.exitCode = 1;
});
Both halves do identical work; the loop waits for each page before starting the next, while
Promise.all starts all six and waits once. The rule: await inside a loop only when each iteration
needs the previous result, as in a paginated API. Everywhere else, await the array of promises once.
[!IMPORTANT]
awaitonly catches what it actually awaits, and only inside the enclosing function'stry/catch. A rejected promise nobody awaits becomes an unhandled rejection, which Node 20 treats as fatal: the process prints the error and exits non-zero.main().catch(…)above is the smallest honest top-level handler.
Rejections, forEach, and the two silent failures
Two shapes of the same bug. items.forEach(async (item) => { await save(item); }) — forEach ignores
the promise each callback returns, so the loop finishes before any save does and a rejection has no
handler; use for (const item of items) await save(item) to sequence, or Promise.all over
items.map(...) to run concurrently. And a floating promise such as doSomething(); — nothing awaits
it, so nothing can catch it, and if it rejects the process dies with UnhandledPromiseRejection.
Lesson 20 — Error Handling Done Properly starts from exactly that failure.
Converting callback APIs
Older APIs take a callback, so node:util provides promisify: promisify(fsCallbackReadFile) turns
an error-first function into one that returns a promise. It works because the function follows the
convention — last argument a callback, whose first argument is the error — and it fails for methods
that rely on this, which need promisify(obj.method.bind(obj)). In new code you rarely need it:
prefer node:fs/promises to promisify(fs.readFile).
Common mistakes
| Mistake | What happens | The fix |
|---|---|---|
await inside forEach |
The returned promises are ignored; "done" prints early and rejections are lost | for…of with await, or Promise.all(items.map(…)) |
| Awaiting independent calls one by one | Total time is the sum instead of the maximum: six 300 ms calls take 1.8 s, not 0.3 s | Collect the promises and await Promise.all(…) once |
A floating promise with no catch |
Node 20 prints UnhandledPromiseRejection and exits non-zero |
await it, return it, or attach .catch at the top level |
new Promise(async (resolve) => …) |
A rejection inside the executor is swallowed and never rejects the outer promise | Keep executors synchronous; mark the surrounding function async |
Promise.all where partial results are acceptable |
One failed page rejects everything and the successful results are discarded | Promise.allSettled and report which items failed (Lesson 21) |
Try it
- Create
src/pipeline.tsexactly as shown above and run it:
Expected output: sequential 1800 ms, parallel 300 ms (allow 1750–1900 and 290–340) and
total yield: 2562 kWh, plus a new solar-yield.json in the working directory.
-
Open that file and check it holds six objects with
monthandyieldKwh, exactly the shapePagepromises —node -e "const p=require('./solar-yield.json'); console.log(p.length)"prints6. -
Break it on purpose. Replace the
Promise.allline with a second loop that awaits each page, run again, and watch the parallel timing jump to about 1800 ms. Then makeloadPagethrow for"2024-04":Promise.allrejects the wholemainandecho $?reports1— whilePromise.allSettledwould still hand you the other five pages.
Key takeaways
- Callbacks invert control; a promise turns "what happens next" into a value you can pass and combine.
- A promise is pending, then fulfilled or rejected, and settles once; each link in a chain is new.
- An
asyncfunction always returns a promise, andawaitunwraps one only insideasynccode. - Awaiting independent work in a loop serialises it: build the array of promises and await it once.
- Every promise needs an owner, because an unawaited rejection is a crash in Node 20.
Check yourself
- A function declared
function total(): numbernow needs to read a file. What changes in its signature, and what breaks at every call site that ignores the new return type? - Six API calls of 400 ms each are awaited in a
for…ofloop. How long does it take, what is the shortest edit that makes it about 400 ms, and when is the loop version the correct choice? - Why is
items.forEach(async (item) => { await save(item); })dangerous even whensavenever fails? promisifyrequires the wrapped function to follow one convention. Name it, and explain whypromisify(obj.method)so often fails.- Your program logs
UnhandledPromiseRejectionand exits with code 1 on a machine you cannot reach. Which two lines would have made that failure survivable?
