Skip to content

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.

Async patterns compared: sequential awaits, Promise.all, Promise.allSettled, concurrency limits, cancellation and retry policy

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] await only catches what it actually awaits, and only inside the enclosing function's try/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

  1. Create src/pipeline.ts exactly as shown above and run it:
cd ~/dev/ts-zero-to-hero
npx tsc && node dist/pipeline.js

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.

  1. Open that file and check it holds six objects with month and yieldKwh, exactly the shape Page promises — node -e "const p=require('./solar-yield.json'); console.log(p.length)" prints 6.

  2. Break it on purpose. Replace the Promise.all line with a second loop that awaits each page, run again, and watch the parallel timing jump to about 1800 ms. Then make loadPage throw for "2024-04": Promise.all rejects the whole main and echo $? reports 1 — while Promise.allSettled would 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 async function always returns a promise, and await unwraps one only inside async code.
  • 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

  1. A function declared function total(): number now needs to read a file. What changes in its signature, and what breaks at every call site that ignores the new return type?
  2. Six API calls of 400 ms each are awaited in a for…of loop. 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?
  3. Why is items.forEach(async (item) => { await save(item); }) dangerous even when save never fails?
  4. promisify requires the wrapped function to follow one convention. Name it, and explain why promisify(obj.method) so often fails.
  5. Your program logs UnhandledPromiseRejection and exits with code 1 on a machine you cannot reach. Which two lines would have made that failure survivable?

Next

Lesson 20 — Error Handling Done Properly