Node.js NotesRohit’s interview study guide
Chapter 03

Async Patterns

Callbacks, promises and async/await the Node way — combinators, error handling, cancellation and async iteration.

12 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Error-first callbacks

Before promises existed, every asynchronous function in Node core took a callback as its last argument and called it exactly once when the work was done, with the signature (err, result). That's the error-first callback convention. It isn't a language feature — it's an agreement that Node core, npm libraries and your own code all follow, so any caller knows how to read the answer.

Think of the callback as a reply envelope with two compartments. The first compartment is for a problem report; the rest are for the goods. You always open the problem compartment first: if there's something in it, you deal with it and don't touch the goods, because they're missing or untrustworthy. If it's empty (null or undefined), the result is real.

Why put the error first? Because it's the one argument every callback has. Some APIs return one value, some two ((err, bytesRead, buffer)), some none — but slot 0 is always the error. That fixed position is what lets generic tools like util.promisify adapt any callback API without knowing anything about it.

read-callback.cjs
const fs = require("node:fs");

fs.readFile("missing.txt", "utf8", (error, data) => {
  if (error) {
    console.log("code:", error.code, "| errno:", error.errno, "| syscall:", error.syscall);
    console.log("message:", error.message);
    console.log("data:", data);
    return;
  }
  console.log("File content:", data);
});
console.log("readFile called");

// readFile called
// code: ENOENT | errno: -2 | syscall: open
// message: ENOENT: no such file or directory, open 'missing.txt'
// data: undefined
What’s happening
  1. fs.readFile doesn't read anything on the main thread. It hands the open/read work to libuv's thread pool (see Call stack, libuv and the thread pool) and returns undefined straight away, so readFile called prints first.
  2. On a pool thread, the open() system call fails because the file doesn't exist. libuv records the error code -2 (ENOENT, "no such entry") and queues the completion.
  3. On a later loop iteration, Node turns that code into a real Error object with extra properties — code: "ENOENT", errno: -2, syscall: "open", path: "missing.txt" — and calls your callback with it as the first argument.
  4. data is undefined: on failure the result slots are not filled in. That's why the success path must not run — it would be working with nothing.
  5. return ends the callback early. It doesn't "return the error to Node" (Node ignores the callback's return value); it's purely there to stop the success-path code below from running.
AdvancedPropagating errors in your own callback APIs

The same shape is how you propagate errors through your own callback APIs: on failure call callback(err), on success call callback(null, value), and return every time you call it.

load-user.cjs
// A fake database with a Node-style API: it answers on a later turn of the event loop.
const users = { 1: { id: 1, name: "Rohit" } };
const db = {
  getUser(id, callback) {
    setImmediate(() => {
      if (typeof id !== "number") return callback(new TypeError("id must be a number"));
      callback(null, users[id] ?? null);
    });
  },
};

function loadUser(id, callback) {
  db.getUser(id, (err, user) => {
    if (err) return callback(err); // propagate unchanged
    if (!user) return callback(new Error(`User ${id} not found`));
    callback(null, user); // success: error slot is null
  });
}

loadUser(1, (err, user) => console.log("1 →", err, user));
loadUser(2, (err, user) => console.log("2 →", err?.message, user));
loadUser("x", (err, user) => console.log("x →", err?.name, err?.message, user));

// 1 → null { id: 1, name: 'Rohit' }
// 2 → User 2 not found undefined
// x → TypeError id must be a number undefined
What’s happening
  1. All three loadUser calls run synchronously and each one calls db.getUser, which schedules its answer with setImmediate. Nothing prints yet; three callbacks are waiting in the check phase.
  2. Call 1: the fake DB finds the user and calls back with (null, { id: 1, … }). loadUser's inner callback sees no error and a user, so it calls callback(null, user) → 1 → null { id: 1, name: 'Rohit' }.
  3. Call 2: the DB succeeds (err is null) but the user is null. "Not found" is a business failure, so loadUser creates an error and passes it up → 2 → User 2 not found undefined.
  4. Call "x": the DB itself fails with a TypeError. loadUser can't add anything useful, so it passes the same error object up unchanged with return callback(err).
  5. Each layer either handles an error or hands it to its caller. This manual hand-off at every level is exactly the boilerplate promises remove: a rejection travels up the chain on its own.
AdvancedPitfall: a callback called twice
AdvancedWhy try/catch can't catch callback errors

The biggest mental shift is that a callback runs later, on a different call stack, so a try/catch around the call that started the work can't catch anything the callback throws:

outer-try.cjs
const fs = require("node:fs");

try {
  fs.readFile("missing.txt", "utf8", (err, data) => {
    if (err) throw err;
    console.log(data);
  });
  console.log("try block finished");
} catch (err) {
  console.log("outer catch:", err.message); // never runs
}
AdvancedOutput: the throw escapes the outer try
Output
try block finished
/…/outer-try.cjs:5
    if (err) throw err;
             ^

[Error: ENOENT: no such file or directory, open 'missing.txt'] {
  errno: -2,
  code: 'ENOENT',
  syscall: 'open',
  path: 'missing.txt'
}

Node.js v26.7.0
What’s happening
  1. The try block calls fs.readFile, which only starts the read and returns. Then try block finished prints and the try block is over — the catch is now irrelevant.
  2. Some milliseconds later the event loop calls the callback. The call stack is now just "event loop → your callback"; the try/catch frame disappeared long ago.
  3. throw err therefore has no surrounding catch. It becomes an uncaught exception: Node prints the error and exits with code 1.
  4. The fix is to handle the error inside the callback (log it, call your own callback with it, send a 500), or switch to the promise API and await it inside the try — await brings the result back onto a stack that does have your try/catch (see Async/await error handling).
AdvancedRules for a well-behaved callback API

Rules for writing a well-behaved callback API:

  • Call the callback exactly once — never zero times, never twice.
  • Always call it asynchronously. A function that sometimes calls back synchronously (cache hit) and sometimes later (cache miss) makes caller code run in an unpredictable order — the "Zalgo" problem; process.nextTick fixes it.
  • Pass a real Error in the first slot (it has a stack trace), and null there on success.
  • Don't throw from inside an async callback — pass the error on instead.
  • Pick one completion style per function. An async function f(callback) that both calls callback(null, v) and returns v reports completion twice, and a caller that uses both will act twice.

Callbacks are not gone in modern Node. Many core APIs still have callback forms, and when you implement streams you write callback code directly: _write(chunk, encoding, callback) and _transform(chunk, encoding, callback) use error-first callbacks (see Transform streams).

#util.promisify

util.promisify(fn) turns a function that takes an error-first callback into one that returns a promise. It's an adapter: it calls the original function with all your arguments plus one extra callback that it writes itself, and that callback rejects the promise when err is set and resolves it with the result otherwise. It works on any function at all, precisely because the error-first convention fixes where the error is.

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

const readFile = promisify(fs.readFile);

const p = readFile("config.json", "utf8"); // config.json contains {"port": 8080}
console.log(p); // Promise { <pending> }
console.log(JSON.parse(await p)); // { port: 8080 }

try {
  await readFile("nope.json");
} catch (err) {
  console.log("rejected with", err.code); // rejected with ENOENT
}

// The built-in promise API does the same job — prefer it when it exists.
console.log((await readFileP("config.json", "utf8")) === (await p)); // true
What’s happening
  1. promisify(fs.readFile) returns a new function; nothing is read yet.
  2. readFile("config.json", "utf8") creates a promise and, inside its executor, calls fs.readFile("config.json", "utf8", internalCallback). The read starts on the thread pool and the promise is returned pending — that's what the first console.log shows.
  3. When the read completes, libuv calls internalCallback(null, '{"port": 8080}\n'). err is null, so the promise resolves with the string, and await p hands it to JSON.parse.
  4. For nope.json, the internal callback receives an ENOENT error and rejects the promise with that same error object. await turns the rejection into a throw, and the catch reads err.code.
  5. node:fs/promises gives you the promise version natively (no adapter, no extra closure), so you'd only promisify fs.readFile in old code. The same goes for node:timers/promises, node:stream/promises, node:dns/promises and node:readline/promises.
Advancedpromisify written by hand

Here's what promisify does, written by hand. Node's real implementation follows the same plan:

my-promisify.cjs
const custom = Symbol.for("nodejs.util.promisify.custom");

function myPromisify(fn) {
  if (fn[custom]) return fn[custom]; // the function supplied its own promise version

  return function (...args) {
    return new Promise((resolve, reject) => {
      fn.call(this, ...args, (err, value) => {
        if (err) return reject(err);
        resolve(value);
      });
    });
  };
}

const fs = require("node:fs");
const readFile = myPromisify(fs.readFile);
readFile(__filename, "utf8").then((s) => console.log("read", s.length, "chars")); // read 611 chars (this file)
console.log(require("node:util").promisify.custom === custom); // true — prints first
What’s happening
  1. First it checks for a property under the well-known symbol nodejs.util.promisify.custom. If the function provides one, that's returned instead of building a wrapper — this is how functions with non-standard callbacks opt out (next example).
  2. Otherwise it returns a wrapper that collects your arguments with ...args.
  3. The wrapper creates a promise, and in the executor (which runs synchronously) calls the original with your arguments plus a new callback on the end. fn.call(this, …) forwards whatever this the wrapper was called with, so a method called as obj.method() still gets obj.
  4. That callback is the bridge: err → reject(err), otherwise resolve(value). Because the call happens inside the executor, a synchronous throw from fn also turns into a rejection.
  5. The true on the last line shows util.promisify.custom is exactly Symbol.for("nodejs.util.promisify.custom") — a global registered symbol, so libraries can support it without importing util.
  6. Note the limitation in step 4: only one result value survives. A callback that passes (err, a, b) loses b, which is why such APIs need a custom version.
AdvancedSpecial cases: promisify.custom

Some core functions don't fit the (err, value) shape, so Node special-cases them — mostly with promisify.custom:

promisify-custom.mjs
import { promisify } from "node:util";
import dns from "node:dns";
import { exec } from "node:child_process";

// setTimeout's callback has no error slot, and its delay comes first.
console.log(typeof setTimeout[promisify.custom]); // function
const sleep = promisify(setTimeout);
console.log(await sleep(50, "woke")); // woke

// dns.lookup calls back with (err, address, family): two values.
console.log(await promisify(dns.lookup)("localhost")); // { address: '::1', family: 6 } on my machine

// exec calls back with (err, stdout, stderr).
console.log(await promisify(exec)("echo hi")); // { stdout: 'hi\n', stderr: '' }

// Your own function can opt in the same way.
function legacyFetch(url, onDone) {
  setTimeout(() => onDone(url.length), 10); // no error slot at all
}
legacyFetch[promisify.custom] = (url) => new Promise((resolve) => legacyFetch(url, resolve));
console.log(await promisify(legacyFetch)("/api")); // 4
What’s happening
  1. setTimeout(callback, ms, ...args) has the callback first and no error argument, so a generic wrapper can't work. Node attaches a custom version to it, and promisify(setTimeout) returns that: sleep(50, "woke") resolves with "woke" after 50 ms. (It's the same function as setTimeout from node:timers/promises.)
  2. dns.lookup calls back with (err, address, family). A generic wrapper would keep only address, so Node marks lookup internally with the names of its callback values, and promisify resolves with an object { address, family } instead. (This one doesn't use promisify.custom: dns.lookup[promisify.custom] is undefined.) localhost resolved to IPv6 ::1 here; yours may give 127.0.0.1 with family 4.
  3. exec calls back with (err, stdout, stderr) and does define promisify.custom, which resolves with { stdout, stderr }. On failure, the rejection error also carries stdout, stderr and the exit code.
  4. legacyFetch has no error slot, so a generic promisify would treat the first argument (4) as an error and reject. Attaching your own function under promisify.custom tells promisify to use it instead, and await gets 4.
AdvancedEdge case: binding methods before promisifying
AdvancedThe reverse: util.callbackify

The reverse adapter, util.callbackify, wraps an async function so callback-style code can call it:

callbackify.cjs
const { callbackify } = require("node:util");

async function getUser(id) {
  if (id < 0) throw new Error("bad id");
  return { id };
}
const getUserCb = callbackify(getUser);

getUserCb(7, (err, user) => console.log("cb:", err, user));
getUserCb(-1, (err, user) => console.log("cb:", err.message, user));

async function rejectsWithNull() {
  throw null;
}
callbackify(rejectsWithNull)((err) => console.log(err.code, "| reason:", err.reason));

// cb: null { id: 7 }
// cb: bad id undefined
// ERR_FALSY_VALUE_REJECTION | reason: null
What’s happening
  1. callbackify(getUser) returns a function that takes the same arguments plus a callback on the end. It calls getUser(7), waits for the promise, and calls back with (null, { id: 7 }).
  2. For -1, getUser throws, so its promise rejects, and the callback receives (Error("bad id")) with no result.
  3. A rejection with null is a problem: a callback receiving null as err would think it succeeded. So callbackify wraps falsy reasons in an Error with code ERR_FALSY_VALUE_REJECTION and keeps the original in err.reason.
  4. You'd use this when a library still expects a callback (some plugin APIs and old middleware do) but your implementation is async.

#Promises, chaining and the executor

A promise is an object that stands for a result that isn't ready yet. It's in one of three states — pending, then either fulfilled with a value or rejected with a reason — and once settled it never changes. You attach reactions with .then/.catch/.finally, and Node runs those reactions as microtasks after the current code finishes (see process.nextTick and the microtask queues). The general language rules are on the JavaScript site (Promises); here the focus is what they mean for Node code.

The two facts people get wrong: the executor runs synchronously, and a promise is not a thread — it schedules nothing and runs nothing in parallel.

executor.cjs
console.log("A");
const p = new Promise((resolve) => {
  console.log("B");
  resolve("value");
  console.log("B2 — still runs after resolve");
});
p.then((v) => console.log("D", v));
console.log("C");

// A
// B
// B2 — still runs after resolve
// C
// D value
What’s happening
  1. A prints.
  2. new Promise(executor) calls the executor immediately, on the same stack — so B prints before the constructor even returns. There's no "later" involved in creating a promise.
  3. resolve("value") flips the promise to fulfilled. It does not stop the executor: B2 still prints. (Code after resolve runs; only a second resolve/reject is ignored.)
  4. p.then(...) sees an already-fulfilled promise, so it queues its callback as a microtask right away. It doesn't run yet — the current script is still on the stack.
  5. C prints and the script ends. Node drains the microtask queue: D value. Even an already-resolved promise delivers its value asynchronously, which keeps the order predictable.
AdvancedHow .then handlers settle the next promise

Each .then returns a new promise, which is what makes chains work. What the handler does decides how that new promise settles:

chain.cjs
const start = Date.now();
const t = () => `${String(Date.now() - start).padStart(4)} ms`;
const fetchData = () => new Promise((resolve) => setTimeout(() => resolve("Data fetched"), 1000));

const p1 = fetchData();
const p2 = p1.then((result) => {
  console.log(t(), "then #1 got:", result);
  return new Promise((resolve) => setTimeout(() => resolve("Processed data"), 500));
});
const p3 = p2.then((processed) => {
  console.log(t(), "then #2 got:", processed);
  throw new Error("save failed");
});
const p4 = p3.then(() => console.log("never runs"));
const p5 = p4.catch((err) => {
  console.log(t(), "catch got:", err.message);
  return "fallback";
});
p5.then((v) => console.log(t(), "after catch:", v));
console.log(t(), "all distinct promises?", new Set([p1, p2, p3, p4, p5]).size === 5);

//    0 ms all distinct promises? true
// 1001 ms then #1 got: Data fetched
// 1502 ms then #2 got: Processed data
// 1502 ms catch got: save failed
// 1502 ms after catch: fallback
What’s happening
  1. 0 ms: the whole chain is built synchronously. Five different promise objects exist, all pending; the only real work in progress is the 1000 ms timer inside fetchData. The last line proves .then/.catch each returned a new promise.
  2. 1000 ms: p1 fulfils with "Data fetched", so handler #1 runs. It returns a promise. p2 doesn't fulfil with a promise object; it adopts the returned promise's state, so it stays pending for another 500 ms.
  3. 1500 ms: the inner promise fulfils with "Processed data", so p2 does too, and handler #2 runs. It throws, and a throw inside a handler rejects that handler's promise: p3 is rejected with Error("save failed").
  4. p4 = p3.then(onFulfilled) has no rejection handler, so p4 is rejected with the same error and "never runs" is skipped. A rejection falls through every .then until something handles it.
  5. .catch handles it and returns "fallback", so p5 is fulfilled. Catching recovers the chain; the final .then prints after catch: fallback. If the catch had re-thrown, p5 would be rejected and the error would continue down the chain.
AdvancedPitfall: CPU work inside a promise

#Promise.all and running work concurrently

Promise.all doesn't run anything. The work starts when you call the functions that create the promises; Promise.all only waits for the results and combines them. So the real question is never "should I use Promise.all?" but "which operations depend on each other?" Independent operations can be started together; dependent ones must wait.

That gives you concurrency, not parallelism: one thread starts several I/O operations, and the waiting overlaps. Two 1-second database queries started together finish after about 1 second instead of 2 — the database and network do the work while Node's thread does nothing.

parallel.cjs
async function task1() {
  return new Promise((resolve) => setTimeout(() => resolve("Task 1 done"), 1000));
}
async function task2() {
  return new Promise((resolve) => setTimeout(() => resolve("Task 2 done"), 1000));
}

async function runInParallel() {
  const start = Date.now();
  const [result1, result2] = await Promise.all([task1(), task2()]);
  console.log(result1, "|", result2, `| ${Date.now() - start} ms`);
}

async function runInSequence() {
  const start = Date.now();
  const result1 = await task1();
  const result2 = await task2();
  console.log(result1, "|", result2, `| ${Date.now() - start} ms`);
}

runInParallel().then(runInSequence);
// Task 1 done | Task 2 done | 1002 ms
// Task 1 done | Task 2 done | 2003 ms
What’s happening
  1. In runInParallel, the array literal [task1(), task2()] is evaluated first, which calls both functions. Each one starts a 1-second timer and returns a pending promise. Both timers now run at the same time.
  2. Promise.all receives the two promises and returns one that's pending until both fulfil. await suspends runInParallel there.
  3. At about 1000 ms both timers fire; both promises fulfil; Promise.all fulfils with ["Task 1 done", "Task 2 done"] (input order), which destructures into result1 and result2. Total ≈ 1 s.
  4. In runInSequence, await task1() suspends before task2() is even called. Task 2's timer only starts once task 1 is done, so the total is ≈ 2 s. Use this shape only when the second call needs the first result.
  5. In the version from my notes, the async keyword on task1/task2 is redundant when the function just returns a promise — async wraps it in another promise, which costs a couple of extra microtask ticks but changes nothing visible. A plain function returning the promise is enough.
AdvancedPromise.all: input order and plain values

Two more properties of Promise.all show up in interviews: results come back in input order, and non-promise values are allowed.

order.cjs
const delay = (ms, value) => new Promise((resolve) => setTimeout(resolve, ms, value));

async function main() {
  const [first, second] = await Promise.all([delay(50, "First"), delay(10, "Second")]);
  console.log(first, second); // First Second
  console.log(await Promise.all([Promise.resolve("A"), 42, delay(5, "C")])); // [ 'A', 42, 'C' ]
}
main().catch(console.error);
What’s happening
  1. delay(50, "First") and delay(10, "Second") start together. "Second" finishes 40 ms earlier.
  2. Promise.all keeps a results array and stores each value at its input index, not in completion order. So first is "First" even though it finished last — which is what makes array destructuring safe.
  3. In the second call, 42 isn't a promise. Promise.all passes every element through Promise.resolve, so 42 counts as an already-fulfilled value.
  4. The result [ 'A', 42, 'C' ] arrives once the slowest input (delay(5)) fulfils.
AdvancedFail-fast doesn't cancel the others

Fail-fast means the combined promise rejects as soon as one input rejects. It doesn't mean the others stop:

fail-fast.cjs
const start = Date.now();
const job = (name, ms, fail = false) =>
  new Promise((resolve, reject) =>
    setTimeout(() => {
      console.log(`${Date.now() - start} ms: ${name} settles`);
      fail ? reject(new Error(`${name} failed`)) : resolve(name);
    }, ms),
  );

Promise.all([job("user", 1000), job("orders", 500, true), job("recs", 1500)])
  .then((values) => console.log("never runs", values))
  .catch((err) => console.log(`${Date.now() - start} ms: caught ${err.message}`));

// 501 ms: orders settles
// 502 ms: caught orders failed
// 1001 ms: user settles
// 1501 ms: recs settles
What’s happening
  1. 0 ms: all three jobs start. Promise.all is pending.
  2. 500 ms: orders rejects. Promise.all rejects immediately with that error, without waiting for the others, and .catch prints caught orders failed.
  3. 1000 ms and 1500 ms: user and recs still run to completion and log. Promise.all has no way to stop them; it just ignores their results. (Their later settlements are harmless — Promise.all already attached handlers to them, so nothing is reported as unhandled.)
  4. If those jobs were HTTP calls, they'd still use sockets and server time after nobody cares. To actually stop them, pass an AbortSignal and abort it in the catch.
AdvancedPitfall: partial side effects on failure
AdvancedConcurrency following the dependency graph

Concurrency should follow the dependency graph. Here orders and preferences both need the user, but not each other:

dependency-graph.cjs
const wait = (ms, v) => new Promise((r) => setTimeout(r, ms, v));
const getUser = (id) => wait(100, { id });
const getOrders = (userId) => wait(200, ["order-1"]);
const getPreferences = (userId) => wait(150, { theme: "dark" });
const recommend = (orders, prefs) => wait(50, `${orders.length} recs for ${prefs.theme} mode`);

async function dashboard(userId) {
  const start = Date.now();
  const user = await getUser(userId); //                     100 ms
  const [orders, prefs] = await Promise.all([
    getOrders(user.id), //                                    } 200 ms (the slower one)
    getPreferences(user.id), //                               }
  ]);
  const recs = await recommend(orders, prefs); //             50 ms
  console.log(recs, `| ${Date.now() - start} ms`);
}
dashboard(7);
// 1 recs for dark mode | 353 ms
What’s happening
  1. getUser must finish first because both later calls need user.id: 100 ms.
  2. getOrders and getPreferences only need the user, not each other, so they start together. The Promise.all waits for the slower one: 200 ms.
  3. recommend needs both results: another 50 ms. Total ≈ 350 ms (353 on the run above).
  4. All sequential would be 100 + 200 + 150 + 50 = 500 ms. "Everything in one Promise.all" isn't possible — the dependencies forbid it. Optimising async code is mostly drawing this graph.
AdvancedforEach vs map with async callbacks

forEach doesn't wait for async callbacks — it ignores the promises they return. Use map + Promise.all for concurrent work, or for…of + await for sequential:

foreach-vs-map.cjs
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const saved = [];
async function saveUser(id) {
  await wait(10 * id);
  saved.push(id);
}

async function withForEach(ids) {
  ids.forEach(async (id) => {
    await saveUser(id);
  });
  console.log("forEach done, saved =", saved);
}
async function withMap(ids) {
  saved.length = 0;
  await Promise.all(ids.map((id) => saveUser(id)));
  console.log("map + all done, saved =", saved);
}
async function withForOf(ids) {
  saved.length = 0;
  for (const id of ids) await saveUser(id);
  console.log("for…of done, saved =", saved);
}

(async () => {
  await withForEach([3, 1, 2]);
  await wait(50);
  console.log("50 ms later, saved =", saved);
  await withMap([3, 1, 2]);
  await withForOf([3, 1, 2]);
})();

// forEach done, saved = []
// 50 ms later, saved = [ 1, 2, 3 ]
// map + all done, saved = [ 1, 2, 3 ]
// for…of done, saved = [ 3, 1, 2 ]
What’s happening
  1. forEach calls the async callback three times. Each call runs until its first await and returns a promise — which forEach throws away. forEach returns undefined immediately, so withForEach logs saved = [] while all three saves are still in flight.
  2. 50 ms later the saves have finished on their own, in completion order (10 ms, 20 ms, 30 ms → 1, 2, 3). Any error from them would have been an unhandled rejection, because nobody held their promises.
  3. ids.map(...) returns the three promises, and Promise.all waits for all of them. Same concurrency, same completion order [1, 2, 3], but now withMap really waits and sees failures.
  4. for…of with await runs them one at a time in input order: [3, 1, 2], taking 30 + 10 + 20 = 60 ms instead of 30 ms. Choose it when order matters or the downstream can only take one at a time.
AdvancedPitfall: sparse arrays in load tests

#Promise.allSettled, any and race

There are four combinators, and they differ on two questions: do you wait for all of them or just the first? and what counts — a fulfilment, or any settlement?

MethodSettles whenFulfils withRejects withTypical use
Promise.allAll fulfil, or the first rejectsArray of values (input order)The first rejection reasonYou need every result
Promise.allSettledAll settleArray of { status, value | reason }Never (for normal inputs)Batch jobs, dashboards with partial failure
Promise.anyThe first fulfils, or all rejectThe first fulfilment valueAggregateError with every reasonFastest mirror / replica
Promise.raceThe first settles, either wayThe first value, if it fulfilledThe first reason, if it rejectedTimeouts

The clearest way to see them is to run all four on the same inputs and watch the timeline:

combinators.cjs
const start = Date.now();
const t = () => `${String(Math.round((Date.now() - start) / 100) * 100).padStart(4)} ms`;

const p1 = new Promise((resolve) => setTimeout(resolve, 1000, "First"));
const p2 = new Promise((resolve) => setTimeout(resolve, 500, "Second"));
const p3 = new Promise((resolve, reject) => setTimeout(reject, 1500, "Error"));

const report = (name, p) =>
  p.then(
    (v) => console.log(t(), name, "fulfilled →", v),
    (e) => console.log(t(), name, "rejected  →", e),
  );

report("all([p1,p2])   ", Promise.all([p1, p2]));
report("all([p1,p2,p3])", Promise.all([p1, p2, p3]));
report("allSettled     ", Promise.allSettled([p1, p2, p3]));
report("any            ", Promise.any([p1, p2, p3]));
report("race           ", Promise.race([p1, p2, p3]));

//  500 ms any             fulfilled → Second
//  500 ms race            fulfilled → Second
// 1000 ms all([p1,p2])    fulfilled → [ 'First', 'Second' ]
// 1500 ms all([p1,p2,p3]) rejected  → Error
// 1500 ms allSettled      fulfilled → [
//   { status: 'fulfilled', value: 'First' },
//   { status: 'fulfilled', value: 'Second' },
//   { status: 'rejected', reason: 'Error' }
// ]
What’s happening
  1. 0 ms: p1, p2, p3 are all pending, and so are all five combined promises. (setTimeout(resolve, 500, "Second") passes "Second" as the argument to resolve — a handy Node and browser trick.)
  2. 500 ms: p2 fulfils with "Second". any is waiting for the first fulfilment and race for the first settlement — both are satisfied, so both fulfil with "Second". all and allSettled record "Second" in slot 1 and keep waiting.
  3. 1000 ms: p1 fulfils. all([p1, p2]) now has every value and fulfils with ['First', 'Second'] — input order, though p2 finished first. all([p1,p2,p3]) still waits for p3.
  4. 1500 ms: p3 rejects with "Error". all([p1,p2,p3]) rejects with it. If p3 had rejected at 100 ms instead, this all would have rejected at 100 ms — fail-fast — while allSettled would still wait for everything.
  5. allSettled fulfils (it never rejects for a rejected input) with one descriptor per input. any and race had settled at 500 ms, so p3's rejection changes nothing for them.
  6. Note race didn't choose the fulfilment: if p3 had been the fastest, race would have rejected. That's the difference from any.
AdvancedPromise.any and AggregateError

When every input of Promise.any rejects, you get one AggregateError holding all the reasons:

any-all-reject.cjs
const p1 = new Promise((_, reject) => setTimeout(reject, 1000, "Error1"));
const p2 = new Promise((_, reject) => setTimeout(reject, 500, "Error2"));

Promise.any([p1, p2]).catch((error) => {
  console.log(error instanceof AggregateError, error.name, "-", error.message);
  console.log(error.errors);
});
// true AggregateError - All promises were rejected
// [ 'Error1', 'Error2' ]
What’s happening
  1. At 500 ms p2 rejects. any notes the reason and keeps waiting — one failure isn't enough for it to give up.
  2. At 1000 ms p1 rejects too. Now no input can ever fulfil, so any rejects.
  3. The reason is an AggregateError whose .errors array is in input order (Error1 first), not rejection order — the same index rule as Promise.all.
  4. The message is the generic All promises were rejected, so log .errors (or each one's cause) when you handle it, or the real reasons get lost.
AdvancedPromise.race written by hand

Promise.race is easy to write yourself, and doing so shows why "first one wins":

promise-race.cjs
function promiseRace(iterable) {
  return new Promise((resolve, reject) => {
    for (const item of iterable) {
      Promise.resolve(item).then(resolve, reject);
    }
  });
}

const delay = (ms, v, fail) => new Promise((res, rej) => setTimeout(fail ? rej : res, ms, v));

promiseRace([delay(300, "slow"), delay(100, "fast")]).then((v) => console.log("1:", v));
promiseRace([delay(300, "slow"), delay(100, new Error("fast fail"), true)]).catch((e) => console.log("2:", e.message));
promiseRace([delay(50, "timer"), "plain value"]).then((v) => console.log("3:", v));

// 3: plain value
// 1: fast
// 2: fast fail
What’s happening
  1. promiseRace returns one new promise, and wires the same resolve and reject to every input.
  2. Promise.resolve(item) lets inputs be plain values or thenables as well as promises; "plain value" becomes an already-fulfilled promise.
  3. A promise can only settle once; later calls to resolve/reject are silently ignored. So whichever input settles first decides the outcome — that's the whole algorithm.
  4. Race 3 prints first: "plain value" is already fulfilled, so its .then fires in the first microtask pass, before any timer. Race 1 settles at 100 ms with "fast"; race 2 rejects at 100 ms because the fastest input failed.
  5. The losers keep running (the 300 ms timers still fire); their results are ignored. The same is true of the built-in Promise.race. For the general Promise.all polyfill, see the JavaScript site: Polyfill: Promise.all.
AdvancedTimeouts with race: the live-timer catch

The classic use of race is a timeout. In Node there's a catch: a pending timer keeps the process alive.

race-timeout.cjs
const start = Date.now();
const work = new Promise((resolve) => setTimeout(resolve, 100, "result"));
const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("Timed out")), 5000));

Promise.race([work, timeout]).then((v) => console.log(Date.now() - start, "ms: got", v));
process.on("exit", () => console.log(Date.now() - start, "ms: process exits"));

// 102 ms: got result
// 5003 ms: process exits
What’s happening
  1. Both promises start: the work timer (100 ms) and the timeout timer (5000 ms).
  2. At 100 ms the work wins and race fulfils with "result". The program has nothing left to do…
  3. …but the 5-second timer is still referenced (active), and Node won't exit while referenced timers or handles exist. The process hangs around for 4.9 s more.
  4. At 5000 ms the timer fires and rejects timeout. That rejection isn't reported as unhandled, because race attached a handler to every input. Then the process exits. In a CLI script that's a 5-second delay; in a server it's thousands of stale timers piling up.
AdvancedFix: clearing the timer after the race

The fix is to clear the timer once the race is decided:

with-timeout.cjs
const start = Date.now();

function withTimeout(promise, ms) {
  let timer;
  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error(`Timed out after ${ms} ms`)), ms);
  });
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}

const work = new Promise((resolve) => setTimeout(resolve, 100, "result"));
withTimeout(work, 5000).then((v) => console.log(Date.now() - start, "ms: got", v));

const slow = new Promise((resolve) => setTimeout(resolve, 400, "late"));
withTimeout(slow, 200).catch((e) => console.log(Date.now() - start, "ms:", e.message));

process.on("exit", () => console.log(Date.now() - start, "ms: process exits"));
// 103 ms: got result
// 203 ms: Timed out after 200 ms
// 403 ms: process exits
What’s happening
  1. timer is declared outside the executor so finally can reach it.
  2. First call: the work wins at 100 ms; .finally runs clearTimeout(timer), so the 5-second timer is gone.
  3. Second call: the 200 ms timeout wins and rejects with Timed out after 200 ms; finally clears an already-fired timer, which is harmless.
  4. The process still lives until 403 ms, because the slow work's own 400 ms timer keeps running — a timeout stops your waiting, not the work. To stop the work too, it has to accept an abort signal: see Cancelling work with AbortController, where AbortSignal.timeout(ms) replaces this whole helper.
Empty inputs

What does each of these do?

JavaScript
Promise.all([]);
Promise.allSettled([]);
Promise.any([]);
Promise.race([]);
Show answer

all([]) and allSettled([]) fulfil with []; any([]) rejects with an AggregateError (All promises were rejected, errors: []); race([]) stays pending forever.

What’s happening
  1. all fulfils when "every input has fulfilled". With zero inputs, that's true straight away, so it fulfils with an empty array (still asynchronously — .then runs as a microtask).
  2. allSettled uses the same counting logic, so it also fulfils with [].
  3. any rejects when "every input has rejected" — also vacuously true with zero inputs — so it rejects at once with an empty errors array.
  4. race settles when the first input settles. With no inputs nothing ever settles, so await Promise.race([]) hangs forever. A race built from a filtered list that happens to be empty is a real bug.

#Limiting concurrency

Promise.all(items.map(doWork)) starts every operation at once. With 10 items that's fine. With 50,000 it isn't: in Node each operation holds memory and usually a scarce resource — a database connection from a pool of 10, a file descriptor (open too many and you get EMFILE), one of libuv's 4 thread-pool threads for fs and crypto work, or a share of an API's rate limit. Opening everything at once doesn't make the downstream any faster; it just builds a queue somewhere you can't see it, and often makes things fail.

The fix is a concurrency limit: at most N operations in flight, and a new one starts the moment a slot frees up. The JavaScript site builds this as a worker-pool function, mapWithLimit (Limiting concurrency). In Node services it's often handier as a semaphore you can share between code paths — for example, one semaphore guarding all calls to a payments API.

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

class Semaphore {
  #free;
  #waiting = [];
  constructor(limit) {
    this.#free = limit;
  }
  async acquire() {
    if (this.#free > 0) {
      this.#free--;
      return;
    }
    await new Promise((resolve) => this.#waiting.push(resolve));
  }
  release() {
    const next = this.#waiting.shift();
    if (next) next(); // hand the slot straight to the next waiter
    else this.#free++;
  }
  async run(fn) {
    await this.acquire();
    try {
      return await fn();
    } finally {
      this.release();
    }
  }
}

const start = Date.now();
const t = () => `${String(Date.now() - start).padStart(4)} ms`;
let inFlight = 0;
let maxInFlight = 0;

async function callApi(id, ms) {
  inFlight++;
  maxInFlight = Math.max(maxInFlight, inFlight);
  console.log(t(), `start ${id} (in flight: ${inFlight})`);
  await sleep(ms);
  inFlight--;
  return `user-${id}`;
}

const durations = [300, 100, 100, 200, 100, 100];
const sem = new Semaphore(2);
const users = await Promise.all(durations.map((ms, id) => sem.run(() => callApi(id, ms))));
console.log(t(), users, "max in flight:", maxInFlight);

//    0 ms start 0 (in flight: 1)
//    2 ms start 1 (in flight: 2)
//  104 ms start 2 (in flight: 2)
//  206 ms start 3 (in flight: 2)
//  304 ms start 4 (in flight: 2)
//  406 ms start 5 (in flight: 2)
//  509 ms [ 'user-0', 'user-1', 'user-2', 'user-3', 'user-4', 'user-5' ] max in flight: 2
What’s happening
  1. durations.map(...) calls sem.run six times straight away. Calls 0 and 1 find #free at 2 then 1, take a slot each, and start their API calls. Calls 2–5 find #free === 0, so each pushes its resolve onto #waiting and suspends. Six promises exist, but only two operations are running.
  2. ~100 ms: call 1 (100 ms) finishes. Its finally runs release(), which shifts the first waiter (call 2) and resolves it — the slot passes directly to call 2 without #free going up, so nobody can sneak in between. start 2 logs.
  3. ~200 ms: call 2 finishes and hands its slot to call 3 (200 ms). ~300 ms: call 0 (300 ms) finishes and hands its slot to call 4.
  4. ~400 ms: calls 3 and 4 both finish. The first release starts call 5; the second finds no waiters and increments #free. ~500 ms: call 5 finishes and the Promise.all fulfils.
  5. maxInFlight is 2, and users is still in input order, because Promise.all places each result by index. Total ≈ 500 ms, compared with 900 ms one at a time and 300 ms unlimited.
  6. The try/finally in run matters: if fn throws, the slot is still released. Without it, every failure would permanently shrink the pool until the app deadlocks.
AdvancedFixed batches vs a concurrency pool

Splitting the work into fixed batches also limits concurrency, but each batch waits for its slowest member:

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

const durations = [300, 100, 100, 200, 100, 100];
const start = Date.now();
for (let i = 0; i < durations.length; i += 2) {
  await Promise.all(durations.slice(i, i + 2).map((ms) => sleep(ms)));
  console.log(`batch ${i / 2} done at ${Date.now() - start} ms`);
}
// batch 0 done at 301 ms
// batch 1 done at 506 ms
// batch 2 done at 608 ms
What’s happening
  1. Batch 0 is [300, 100]. The 100 ms task finishes early, but its slot then sits idle for 200 ms because the next batch can't start until the 300 ms task finishes. Done at ~300 ms.
  2. Batch 1 is [100, 200], so another ~200 ms → ~500 ms. Batch 2 is [100, 100] → ~600 ms.
  3. The semaphore did the same work in ~500 ms because it refills a slot the moment it frees. Batches are simpler and fine for one-off scripts; a pool or semaphore is better for steady work with uneven durations.
AdvancedStreams: Readable.map with concurrency

Node's streams can do the same job: Readable.from(items).map(fn, { concurrency }) runs fn on up to concurrency items at a time and keeps the output in input order. (Check the docs for these stream helpers' stability on your version; on Node 26.7 they ran without any warning.)

stream-map.mjs
import { Readable } from "node:stream";
import { setTimeout as sleep } from "node:timers/promises";

const start = Date.now();
const out = await Readable.from([300, 100, 100, 200, 100, 100])
  .map(async (ms) => {
    await sleep(ms);
    return ms;
  }, { concurrency: 2 })
  .toArray();
console.log(out, Date.now() - start, "ms");
// [ 300, 100, 100, 200, 100, 100 ] 505 ms
What’s happening
  1. Readable.from(array) makes an object-mode stream that emits the six numbers.
  2. .map(fn, { concurrency: 2 }) pulls items and keeps at most two fn calls in flight, the same schedule as the semaphore — so it also takes ~500 ms.
  3. Results come out in input order (300 first, though it finished after the next two 100s): the helper buffers early finishers until their turn.
  4. .toArray() collects everything into one array. For huge inputs, consume the stream with for await instead, so results don't pile up in memory (see Streams as async iterables).
AdvancedChoosing the concurrency limit

How to pick the limit: from what the downstream can take, not a magic number — the size of your DB pool, the API's documented rate, the number of thread-pool threads (UV_THREADPOOL_SIZE, 4 by default) for fs/crypto work. In production, p-limit and p-map from npm do exactly this.

#Async/await error handling

async/await is promises with nicer syntax, and the error rules follow from two facts (the general mechanics are on the JavaScript site: async/await):

  • An async function always returns a promise. return x fulfils it; throw e (or an awaited rejection that isn't caught) rejects it.
  • await p unwraps a fulfilled promise into a value, and turns a rejected promise into a throw at that exact line.

So a normal try/catch works — but only around promises that are actually awaited inside the try.

await-errors.cjs
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
async function getUser() {
  await wait(10);
  return { id: 7 };
}
async function getOrders(id) {
  await wait(10);
  throw new Error(`orders service down for user ${id}`);
}

async function loadDashboard() {
  try {
    const user = await getUser();
    const orders = await getOrders(user.id);
    return { user, orders };
  } catch (err) {
    throw new Error("Dashboard failed", { cause: err }); // add context, keep the original
  }
}
loadDashboard().catch((err) => console.log(err.message, "←", err.cause.message));

async function notAwaited() {
  try {
    getOrders(1); // started, NOT awaited
    console.log("try finished without error");
  } catch (err) {
    console.log("never printed");
  }
}
notAwaited();

process.on("unhandledRejection", (r) => console.log("unhandledRejection:", r.message));

// try finished without error
// unhandledRejection: orders service down for user 1
// Dashboard failed ← orders service down for user 7
What’s happening
  1. loadDashboard() runs until await getUser() and suspends. notAwaited() runs next: it calls getOrders(1), gets back a pending promise, ignores it, logs try finished without error, and leaves the try block. The try covered only the synchronous call, which didn't throw.
  2. ~10 ms: getUser fulfils, and loadDashboard resumes and calls getOrders(7). Meanwhile getOrders(1) throws inside its async body, which rejects its promise. Nobody holds that promise, so after the microtask queue drains Node emits unhandledRejection. (Without the listener at the bottom, the process would crash here — see the next topic.)
  3. ~20 ms: getOrders(7) rejects. Because loadDashboard is suspended at await getOrders(user.id), the rejection is thrown at that line, inside the try, and the catch gets it.
  4. The catch adds context and rethrows: new Error("Dashboard failed", { cause: err }) keeps the original error in .cause (stack and all), so logs show both "what we were doing" and "what went wrong". That rejects loadDashboard()'s promise, and the caller's .catch prints both messages.
Advancedreturn vs return await inside try

A subtle one: return promise vs return await promise inside a try.

return-await.cjs
async function fails() {
  throw new Error("boom");
}

async function withoutAwait() {
  try {
    return fails();
  } catch {
    return "caught inside";
  }
}
async function withAwait() {
  try {
    return await fails();
  } catch {
    return "caught inside";
  }
}

withoutAwait().then((v) => console.log("without await:", v), (e) => console.log("without await: rejected with", e.message));
withAwait().then((v) => console.log("with await:", v));
// with await: caught inside
// without await: rejected with boom
What’s happening
  1. In withoutAwait, return fails() returns the rejected promise object. Returning isn't throwing, so the try completes normally and the catch is skipped.
  2. The async function's own promise then adopts the returned promise's state and rejects with boom — the local catch never saw it.
  3. In withAwait, await fails() throws boom inside the try, so the catch runs and the function fulfils with "caught inside".
  4. It also prints first because adopting a returned promise costs extra microtask ticks. Rule: inside a try, write return await. Outside a try, return promise and return await promise behave the same.
AdvancedTwo ways to observe an async failure

Rohit's notes give the two ways of observing an async function's failure, and they're two views of the same rejected promise:

two-styles.cjs
async function fetchData() {
  throw new Error("network down");
}

// 1. try/catch around an awaited call
(async () => {
  try {
    await fetchData();
  } catch (err) {
    console.log("try/catch:", err.message);
  }
})();

// 2. .catch() on the returned promise
fetchData().catch((err) => console.log(".catch():", err.message));

// try/catch: network down
// .catch(): network down
What’s happening
  1. Each call to fetchData() returns a new promise that is already rejected — an async function converts its throw into a rejection; it never throws synchronously at the call site.
  2. Style 1 awaits it inside a try, so the rejection is rethrown at await and caught.
  3. Style 2 attaches a rejection handler directly. Same result, no wrapper function needed — handy at the top of a script or for fire-and-forget work.
  4. Both handlers are attached in the same synchronous pass, so neither rejection is ever "unhandled".
AdvancedWhere to catch async errors

Where should you catch? Only where you can do something useful:

  • Recover — fall back to a cache, return a default, retry according to a policy (see Retries with exponential backoff and jitter).
  • Translate — turn a driver error into a domain error, or a "not found" into a 404.
  • Add context — rethrow with { cause }, like loadDashboard above.
  • At a boundary — the HTTP handler or error middleware, the job runner, the CLI entry point — log once and respond.

Everywhere else, let the rejection propagate. A catch that just logs and carries on is worse than none: the function now fulfils with undefined, and the caller proceeds as if it worked.

#Floating promises and unhandled rejections

A floating promise is one that nobody awaits, returns or attaches .catch to. If it rejects, there's no code in place to hear about it — the error has escaped the program's control flow. Node calls that an unhandled rejection, and since Node 15 the default response is to crash the process: the rejection is raised like an uncaught exception, and Node exits with code 1.

When exactly is a rejection "unhandled"? Node checks after the microtask queue drains: if a promise rejected and still has no handler by then, it's reported. So attaching a handler a few awaits later is fine; attaching it in a timer is too late.

unhandled.cjs
async function run() {
  throw new Error("Database unavailable");
}
run(); // returned promise ignored
setTimeout(() => console.log("timer: still alive?"), 100);
console.log("end of script");
AdvancedOutput: the process crashes on rejection
Output
end of script
/…/unhandled.cjs:2
  throw new Error("Database unavailable");
        ^

Error: Database unavailable
    at run (/…/unhandled.cjs:2:9)
    at Object.<anonymous> (/…/unhandled.cjs:4:1)
    …

Node.js v26.7.0
What’s happening
  1. run() throws inside an async function, so it doesn't throw at the call site — it returns a rejected promise. The script ignores it, schedules a 100 ms timer and logs end of script.
  2. The script's synchronous code ends and Node drains microtasks. The promise is still rejected with no handler, so Node emits unhandledRejection on process.
  3. With no listener for that event, the default --unhandled-rejections=throw mode raises the error as an uncaught exception. Node prints the stack and exits with code 1.
  4. timer: still alive? never prints — the process died at the end of its first tick. For a server, that's every in-flight request dropped. Better a loud crash and a restart than a process running on in an unknown state.
  5. If you reject with something that isn't an Error (Promise.reject("just a string")), Node can't show a stack, so it wraps it: UnhandledPromiseRejection: This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason "just a string". with code ERR_UNHANDLED_REJECTION. Always reject with Error objects.
AdvancedLate handlers: when a rejection is unhandled

The timing rule is easiest to see side by side:

late-handlers.cjs
// Handler attached after two awaits: still within the same microtask drain → fine.
const p1 = Promise.reject(new Error("p1"));
(async () => {
  await null;
  await null;
  p1.catch((e) => console.log("caught after two awaits:", e.message));
})();

// Handler attached in a timer: the microtask queue drained first → crash.
const p2 = Promise.reject(new Error("p2"));
setTimeout(() => p2.catch((e) => console.log("caught in timer:", e.message)), 0);

// caught after two awaits: p1
// …then the process crashes with "Error: p2" (exit code 1)
What’s happening
  1. Both promises are rejected immediately, with no handlers yet.
  2. The async IIFE runs to its first await null and suspends. Each await resumes in a microtask, so after two more microtask hops it attaches p1.catch. All of this happens while Node is draining microtasks, before the unhandled check — so p1 counts as handled.
  3. When the queue is empty, Node checks: p1 has a handler, p2 doesn't. p2 is reported, the default mode crashes the process, and the timer that would have attached its handler never runs.
  4. The practical rule: attach a handler (or await) in the same synchronous pass that creates the promise, or soon after in microtasks — never "later".
AdvancedPitfall: a second promise rejects unobserved
AdvancedIntentional fire-and-forget promises

A floating promise is sometimes intentional — analytics, a cache warm-up, a best-effort audit log. Then make the decision visible and still give it a failure policy:

fire-and-forget.cjs
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
async function sendAnalytics(order) {
  await wait(20);
  throw new Error("analytics endpoint 503");
}
async function saveToDb(order) {
  await wait(10);
  return { ...order, saved: true };
}

async function saveOrder(order) {
  // Required work: awaited, so its failure fails saveOrder.
  const saved = await saveToDb(order);
  // Optional work: deliberately not awaited, but its failure is still handled.
  void sendAnalytics(order).catch((err) => console.log("warn: analytics failed —", err.message));
  return saved;
}

saveOrder({ id: 42 }).then((o) => console.log("order saved:", o));
// order saved: { id: 42, saved: true }
// warn: analytics failed — analytics endpoint 503
What’s happening
  1. saveToDb is awaited: the order isn't "saved" until it is, and its failure must reject saveOrder.
  2. sendAnalytics(order) starts and its promise immediately gets a .catch, so its later failure is handled — logged as a warning, not a crash.
  3. void throws away the promise .catch returns. It does nothing at runtime; it tells readers (and lint rules such as @typescript-eslint/no-floating-promises) that not awaiting is deliberate.
  4. saveOrder fulfils at ~10 ms and the caller logs order saved. At ~20 ms analytics fails and logs a warning. The user never waited for analytics, and analytics never crashed the server.
AdvancedProcess-level last-resort hooks

Node has two process-level last-resort hooks: unhandledRejection for promises and uncaughtException for synchronous throws that reach the event loop. From my notes, a global handler for rejections:

last-resort.cjs
process.on("unhandledRejection", (reason, promise) => {
  console.error("Unhandled rejection:", reason);
  process.exitCode = 1;
  // Optionally: flush logs, then shut down. Don't keep serving.
});

process.on("uncaughtException", (err, origin) => {
  console.error(`Uncaught (${origin}):`, err.message);
  process.exit(1); // state is unknown — restart
});

Promise.reject(new Error("rejected promise"));
setTimeout(() => {
  throw new Error("thrown in a timer");
}, 0);

// Unhandled rejection: Error: rejected promise
//     at … (stack trace)
// Uncaught (uncaughtException): thrown in a timer
What’s happening
  1. Registering an unhandledRejection listener replaces the default crash. The rejected promise reaches the listener with its reason and the promise itself. The process keeps running unless you stop it.
  2. The setTimeout callback throws with nothing above it to catch it, so uncaughtException fires with origin "uncaughtException". (If there were no unhandledRejection listener, the rejection would arrive here instead with origin "unhandledRejection".)
  3. Handling uncaughtException and carrying on is dangerous: the throw may have happened halfway through updating some state, leaving locks held or data half-written. Log, flush, exit, and let your process manager or Kubernetes restart you; for servers, do a graceful shutdown with a deadline.
  4. Prefer process.exitCode = 1 over process.exit(1) when you have async cleanup to finish: process.exit stops immediately, and an asynchronous logger may lose its last lines.
  5. These hooks are a safety net for bugs. Normal failures should be handled by the code that started the work.
AdvancedThe --unhandled-rejections modes

The --unhandled-rejections flag changes the default. Verified on Node 26.7:

ModeWhat happens
throw (default)Emit unhandledRejection; if nobody listens, raise it as an uncaught exception (crash, exit 1)
strictRaise as an uncaught exception first; an unhandledRejection listener alone doesn't stop the crash (an uncaughtException listener does)
warnPrint UnhandledPromiseRejectionWarning and keep running (exit 0)
warn-with-error-codeWarn, keep running, set process.exitCode = 1
noneSilence

#A higher-order wrapper for async errors

If every async function needs the same try/catch — log, add context, rethrow — you can write it once as a higher-order function: a function that takes your async function and returns a new one with the error handling built in. It's the decorator pattern. This is the version from my notes:

handle-async.cjs
// HOF for consistent error handling
const handleAsync = (fn) => async (...args) => {
  try {
    return await fn(...args);
  } catch (error) {
    console.error("Error caught in HOF:", error.message); // Centralized logging
    throw error; // Rethrow if further handling is needed at the top level
  }
};

const fetchData = handleAsync(async () => {
  throw new Error("Failed to fetch data");
});
const processData = handleAsync(async () => {
  console.log("Processing data...");
});
const saveData = handleAsync(async () => {
  console.log("Saving data...");
});

(async () => {
  try {
    await fetchData();
    await processData();
    await saveData();
  } catch (error) {
    console.error("Final handler:", error.message);
  }
})();

// Error caught in HOF: Failed to fetch data
// Final handler: Failed to fetch data
What’s happening
  1. handleAsync(fn) returns a new async function that closes over fn. Three wrapped functions are created; nothing runs yet.
  2. The IIFE calls fetchData(), i.e. the wrapper. It calls the original, which rejects. Because the wrapper says return await fn(...args), the rejection is thrown inside its try (without await, its catch would be skipped — see return await).
  3. The wrapper's catch logs Error caught in HOF: … and rethrows, so the wrapper's promise rejects with the same error.
  4. Back in the IIFE, await fetchData() throws. Control jumps to the IIFE's catch, so processData and saveData never run — the sequence stops at the first failure, as it should.
  5. Without the rethrow, the wrapper would fulfil with undefined, and the IIFE would cheerfully go on to process and save data that was never fetched. A wrapper that swallows errors hides them.
AdvancedProduction wrapper: this, cause and [err, value]

A more production-ready version keeps this, adds context with cause, and offers a "Go-style" [err, value] helper for places where you'd rather branch than catch:

with-context.cjs
const withContext = (name, fn) =>
  async function (...args) {
    const started = Date.now();
    try {
      return await fn.apply(this, args); // keep the caller's `this`
    } catch (err) {
      throw new Error(`${name} failed after ${Date.now() - started} ms`, { cause: err });
    }
  };

const getUser = withContext("getUser", async (id) => {
  await new Promise((r) => setTimeout(r, 30));
  if (id === 0) throw new Error("no user 0");
  return { id };
});

const settle = async (promise) => {
  try {
    return [null, await promise];
  } catch (err) {
    return [err, null];
  }
};

getUser(5).then((u) => console.log("ok:", u));
getUser(0).catch((e) => console.log(e.message, "| cause:", e.cause.message));
(async () => {
  const [err, user] = await settle(getUser(0));
  console.log("settle:", err?.message, user);
})();

// ok: { id: 5 }
// getUser failed after 32 ms | cause: no user 0
// settle: getUser failed after 32 ms null
What’s happening
  1. withContext returns a regular async function (not an arrow) so it can receive a this and pass it on with fn.apply(this, args) — wrapping a method then doesn't break it.
  2. getUser(5) succeeds after 30 ms, and the wrapper passes the value through untouched.
  3. getUser(0) fails. The wrapper rethrows a new error that says which operation failed and how long it took, with the original in .cause. Logging libraries and Node's own util.inspect print the cause chain.
  4. settle never rejects: it fulfils with [err, null] or [null, value]. That reads well in code that handles failure inline (if (err) return res.status(404)…), but use it sparingly — every caller must remember to check err.
AdvancedExpress 4 and asyncHandler

The best-known instance of this pattern is Express's asyncHandler. In Express 4, the router ignored the promise an async handler returned, so a rejection never reached your error middleware — it became an unhandled rejection (a crash, on modern Node). The wrapper fixed that by catching and calling next(err). Express 5 does this itself. Tested with Express 5.2.1 and supertest:

express-async.cjs
const express = require("express");
const request = require("supertest");

const app = express();
const findUser = async (id) => {
  await new Promise((r) => setTimeout(r, 5));
  if (id !== "1") throw Object.assign(new Error(`User ${id} not found`), { status: 404 });
  return { id, name: "Rohit" };
};

// Express 5: a rejected promise from the handler goes to error middleware by itself.
app.get("/users/:id", async (req, res) => {
  res.json(await findUser(req.params.id));
});

// The Express 4-era wrapper — harmless in Express 5, required in Express 4.
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.get("/v4-style/:id", asyncHandler(async (req, res) => {
  res.json(await findUser(req.params.id));
}));

app.use((err, req, res, next) => {
  res.status(err.status ?? 500).json({ error: err.message });
});

(async () => {
  for (const url of ["/users/1", "/users/2", "/v4-style/2"]) {
    const r = await request(app).get(url);
    console.log(url, r.status, JSON.stringify(r.body));
  }
})();

// /users/1 200 {"id":"1","name":"Rohit"}
// /users/2 404 {"error":"User 2 not found"}
// /v4-style/2 404 {"error":"User 2 not found"}
What’s happening
  1. request(app).get(url) (supertest) starts the app on a temporary port, sends the request and resolves with the response — no server left listening.
  2. /users/1: findUser resolves, res.json sends the user. Status 200.
  3. /users/2: findUser rejects, so the async handler's promise rejects. Express 5 notices the returned promise rejected and calls next(err) for you, which runs the four-argument error middleware: 404 with the message.
  4. /v4-style/2: asyncHandler returns a normal three-argument function. Promise.resolve(fn(...)) covers handlers that return a value as well as a promise, and .catch(next) forwards the error explicitly. Same result — this is what you needed in Express 4.
  5. Neither covers a promise you detach inside a handler (a setTimeout, or a call that isn't awaited or returned). Express only sees the promise the handler returns, so detached work needs its own .catch. More in Error-handling middleware.

#Cancelling work with AbortController

A promise has no cancel method. It's a receipt for work that's already running; once you have it, all you can do is wait. Cancellation in Node is a separate signal you pass into the work: an AbortController holds the power to cancel (controller.abort(reason)), and its controller.signal goes to everything that should stop. One signal can cancel many operations at once. The basics (and fetch) are on the JavaScript site: Cancelling with AbortController.

A lot of Node core accepts { signal }: fs/promises readFile/writeFile, timers/promises, stream.pipeline and stream/promises, events.once/events.on, child_process.spawn/exec, http.request, fetch, readline. The part that's Node-specific — and easy to get wrong — is what you get rejected with:

abort-errors.mjs
import { setTimeout as sleep } from "node:timers/promises";
import { readFile } from "node:fs/promises";

// 1. abort() with no reason
const ac = new AbortController();
setTimeout(() => ac.abort(), 100);
try {
  await sleep(5000, "done", { signal: ac.signal });
} catch (err) {
  console.log("1:", err.name, err.code, "| cause:", err.cause.name);
}

// 2. abort(reason)
const ac2 = new AbortController();
const p2 = sleep(5000, "done", { signal: ac2.signal });
ac2.abort(new Error("user navigated away"));
try {
  await p2;
} catch (err) {
  console.log("2:", err.name, err.code, "| cause:", err.cause.message);
}

// 3. a timeout signal
try {
  await sleep(5000, null, { signal: AbortSignal.timeout(50) });
} catch (err) {
  console.log("3:", err.name, "| cause:", err.cause.name, "-", err.cause.message);
}

// 4. an already-aborted signal
try {
  await readFile("config.json", { signal: AbortSignal.abort() });
} catch (err) {
  console.log("4:", err.name, err.code);
}

// 1: AbortError ABORT_ERR | cause: AbortError
// 2: AbortError ABORT_ERR | cause: user navigated away
// 3: AbortError | cause: TimeoutError - The operation was aborted due to timeout
// 4: AbortError ABORT_ERR
What’s happening
  1. sleep(5000, …, { signal }) starts a real timer and listens to the signal. At 100 ms ac.abort() sets signal.aborted = true and signal.reason to a DOMException named AbortError. The timer is cleared — the work really stops, unlike the race timeout earlier — and the promise rejects.
  2. Node core APIs reject with their own AbortError (err.name === "AbortError", err.code === "ABORT_ERR") and put the signal's reason in err.cause. In case 1 the cause is the default DOMException.
  3. Case 2 passes a reason. The rejection is still Node's AbortError, but err.cause is your Error("user navigated away"). Note the abort happened before the try — the rejection is handled because await p2 attaches a handler in the same synchronous pass.
  4. AbortSignal.timeout(50) aborts itself after 50 ms with a TimeoutError DOMException as its reason; again Node wraps it, so check err.cause.name === "TimeoutError" to tell a timeout from a deliberate cancel.
  5. AbortSignal.abort() creates a signal that is already aborted. readFile checks it first and rejects without opening the file.
  6. Contrast fetch, which follows the web spec: abort() rejects with the DOMException AbortError itself, abort(reason) rejects with your reason directly, and a timeout rejects with the TimeoutError directly (all verified on Node 26.7). So match on err.name === "AbortError" and look at err.cause for Node APIs, or check signal.aborted and signal.reason, which work for both.
AdvancedCombining signals with AbortSignal.any

Combining signals is common in servers — "cancel if the client disconnects or after 5 seconds":

signal-any.mjs
const user = new AbortController();
const combined = AbortSignal.any([user.signal, AbortSignal.timeout(1000)]);

combined.addEventListener("abort", () => console.log("combined aborted with:", combined.reason.message));
user.abort(new Error("cancel button"));
// combined aborted with: cancel button
What’s happening
  1. AbortSignal.any([...]) returns a new signal that aborts as soon as any of its inputs aborts, taking that input's reason.
  2. user.abort(...) fires first, so combined.reason is the cancel button error. If the user had waited 1 s, the reason would be the TimeoutError.
  3. Pass combined to every operation in the request (DB query, downstream fetch, a pipeline). In an HTTP handler, the user side is usually an AbortController aborted from req.on("close") when the client goes away before you respond.
AdvancedMaking your own function cancellable

To make your own function cancellable, accept a signal, check it before starting, listen for abort while waiting, and clean up both ways:

abortable.mjs
function delay(ms, { signal } = {}) {
  signal?.throwIfAborted(); // already cancelled? fail fast
  return new Promise((resolve, reject) => {
    const onAbort = () => {
      clearTimeout(timer); // stop the real work
      reject(signal.reason);
    };
    const timer = setTimeout(() => {
      signal?.removeEventListener("abort", onAbort); // don't leak the listener
      resolve(ms);
    }, ms);
    signal?.addEventListener("abort", onAbort, { once: true });
  });
}

async function processBatch(items, { signal }) {
  for (const item of items) {
    signal.throwIfAborted(); // checkpoint between steps
    await delay(100, { signal });
    console.log("processed", item);
  }
}

const start = Date.now();
try {
  await processBatch(["a", "b", "c", "d"], { signal: AbortSignal.timeout(250) });
} catch (err) {
  console.log(`${Date.now() - start} ms:`, err.name, "-", err.message);
}

// processed a
// processed b
// 251 ms: TimeoutError - The operation was aborted due to timeout
What’s happening
  1. throwIfAborted() throws signal.reason if the signal is already aborted, so cancelled work never starts.
  2. delay starts a timer and registers onAbort with { once: true }. If the timer wins, it removes the listener — important when one long-lived signal is shared by thousands of calls, or the listeners (and their closures) pile up.
  3. 100 ms: item a done. 200 ms: item b done. The third delay starts at 200 ms.
  4. 250 ms: the timeout signal aborts. onAbort clears the pending timer (item c really stops) and rejects with signal.reason — the TimeoutError. Since this function rejects with the reason itself (like fetch), the catch sees TimeoutError directly.
  5. The rejection propagates out of processBatch's loop, so d is never started. The checkpoint at the top of the loop covers steps that can't be interrupted midway.

#Async generators and for await

An async generator (async function*) produces a sequence of values over time, where getting each value may need await. A promise is one future value; an async generator is many, delivered one at a time on request. It's pull-based: the consumer asks for the next value, the generator runs until its next yield, and pauses again. Nothing is fetched ahead of what the consumer asked for. (Plain generators and the iteration protocol are on the JavaScript site: Generators, Async iterators and for await…of.)

That makes it a natural fit for paginated APIs, database cursors, queue consumers and line-by-line file reads: the consumer writes a simple loop, and the generator hides page numbers and tokens.

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

// Fake paginated API: 5 items, 2 per page.
const ITEMS = ["a", "b", "c", "d", "e"];
async function getPage(page) {
  console.log(`  [api] GET /items?page=${page}`);
  await sleep(20);
  const items = ITEMS.slice((page - 1) * 2, page * 2);
  return { items, next: page * 2 < ITEMS.length ? page + 1 : null };
}

async function* allItems() {
  let page = 1;
  try {
    while (page) {
      const { items, next } = await getPage(page);
      for (const item of items) yield item;
      page = next;
    }
    return "finished"; // a return value: for await never sees it
  } finally {
    console.log("  [gen] cleanup");
  }
}

for await (const item of allItems()) {
  console.log("consumer got", item);
  if (item === "c") break;
}

//   [api] GET /items?page=1
// consumer got a
// consumer got b
//   [api] GET /items?page=2
// consumer got c
//   [gen] cleanup
What’s happening
  1. allItems() runs none of its body; it returns an async iterator object. for await calls its next(), which returns a promise.
  2. The generator runs: page 1 is requested ([api] GET …page=1), the await suspends it for 20 ms, then it reaches yield "a" and pauses. The next() promise fulfils with { value: "a", done: false } and the loop body prints consumer got a.
  3. The next next() resumes right after the yield: the inner for yields "b" with no API call. Only when page 1 is used up does the while loop fetch page 2 — the log shows each page is fetched only when the consumer needs it.
  4. On "c", the consumer breaks. for await calls the iterator's return(), which resumes the generator as if return were at the paused yield, so the finally block runs: [gen] cleanup. Page 3 is never requested.
  5. That finally is where you'd close a DB cursor or release a connection, and it runs on normal completion, on break, and on errors thrown in the loop body.
AdvancedDriving an async generator by hand

Driving the same generator by hand shows the protocol, and answers "return vs yield": yield produces values with done: false; return ends the sequence with done: true, and its value is visible only to manual next() calls.

paginate-by-hand.mjs
// (allItems and getPage as in the previous example)
const it = allItems();

const first = it.next();
console.log(first); // Promise { <pending> }
console.log(await first); // { value: 'a', done: false }

let r;
while (!(r = await it.next()).done) console.log(r);
console.log(r); // { value: 'finished', done: true }
console.log(await it.next()); // { value: undefined, done: true }

// Full output:
//   [api] GET /items?page=1
// Promise { <pending> }
// { value: 'a', done: false }
// { value: 'b', done: false }
//   [api] GET /items?page=2
// { value: 'c', done: false }
// { value: 'd', done: false }
//   [api] GET /items?page=3
// { value: 'e', done: false }
//   [gen] cleanup
// { value: 'finished', done: true }
// { value: undefined, done: true }
What’s happening
  1. Every next() returns a promise of { value, done } — that's the difference from a sync iterator — and the first one starts the body (GET page=1).
  2. The loop keeps calling next() while done is false, getting b–e and triggering pages 2 and 3 along the way.
  3. After "e", page 3 has next: null, so the while exits and return "finished" runs. The finally logs cleanup, then next() fulfils with { value: "finished", done: true }.
  4. for await stops as soon as it sees done: true and ignores that value — which is why the first example never printed "finished". If a value matters to the consumer, yield it.
  5. Once finished, the generator stays finished: every further next() gives { value: undefined, done: true }.
AdvancedEvents as async iterables with events.on

Event emitters can be consumed the same way with events.on, which turns a stream of events into an async iterable:

events-on.mjs
import { EventEmitter, on } from "node:events";

const orders = new EventEmitter();
const ac = new AbortController();

setTimeout(() => {
  orders.emit("order", { id: 1 });
  orders.emit("order", { id: 2 }); // emitted while the loop is still busy with #1
  setTimeout(() => orders.emit("order", { id: 3 }), 10);
}, 0);

try {
  for await (const [order] of on(orders, "order", { signal: ac.signal })) {
    console.log("handling order", order.id);
    await new Promise((r) => setTimeout(r, 50)); // slow handler
    if (order.id === 3) ac.abort();
  }
} catch (err) {
  console.log("loop ended with", err.name);
}
console.log("listeners left:", orders.listenerCount("order"));

// handling order 1
// handling order 2
// handling order 3
// loop ended with AbortError
// listeners left: 0
What’s happening
  1. on(orders, "order", { signal }) adds a listener and returns an async iterator. Each item is the array of arguments passed to emit, hence const [order].
  2. Orders 1 and 2 are emitted back to back. The loop takes order 1 and spends 50 ms on it; order 2 arrives meanwhile and is queued inside the iterator, not lost.
  3. When the body finishes, for await asks for the next value and gets order 2 at once, then waits for order 3. So this loop handles events one at a time — a simple way to serialise async event handling, which plain emitter.on with an async listener doesn't do (see Async listeners).
  4. ac.abort() makes the iterator reject with AbortError, which ends the loop and lands in the catch. Either way (abort or break) the iterator removes its listener: listeners left: 0.
  5. An events.on loop never ends by itself — an emitter has no "end" — so give it a signal or a break condition. With default options the queue in step 2 is unbounded too: if events keep arriving faster than you handle them, it keeps growing.
AdvancedStreams and readline as async iterables

Readable streams and readline interfaces are async iterables too, which is the most common use of for await in Node — see Streams as async iterables.

AdvancedEdge case: one value at a time

#this in callbacks, timers and listeners

For a regular function, this is decided by how it's called, not where it was written; an arrow function has no this of its own and uses the one from the surrounding code (the full rules: How this works, Lexical this with arrow functions). When you hand a regular function to Node as a callback, Node does the calling — so Node decides its this. Different APIs choose differently, which is the part interviews probe.

This is the tricky question from my notes:

timer-this.cjs
const obj = {
  value: 42,
  getValue: function () {
    console.log(this.value); // 42 — called as obj.getValue()

    setTimeout(function () {
      console.log(this.value); // undefined — `this` is not obj here
      console.log(this.constructor.name); // Timeout
    }, 1000);

    setTimeout(() => console.log(this.value), 1000); // 42 — arrow keeps getValue's `this`
    setTimeout(function () { console.log(this.value); }.bind(this), 1000); // 42 — bound
  },
};

obj.getValue();
What’s happening
  1. obj.getValue() is a method call, so inside getValue, this === obj and the first log prints 42.
  2. The first setTimeout receives a new regular function. Writing it inside getValue doesn't give it getValue's this — regular functions don't capture this.
  3. A second later Node's timer code calls it. Node calls timer callbacks with the Timeout object as this (the same object setTimeout returned), so this.constructor.name is Timeout, and this.value is undefined because a Timeout has no value property.
  4. The arrow function has no own this; it uses the this of the code it was written in — getValue's, which is obj — so it prints 42. The .bind(this) version creates a function whose this is permanently obj, which Node can't override.
  5. The lesson: a callback never inherits the this of the method that scheduled it. Use an arrow (or bind) when you need the outer object.
AdvancedCorrecting my notes: this in timer callbacks
AdvancedReference: this in each Node callback

Here's what each kind of Node callback receives as this when you pass a regular function (verified on Node 26.7):

Callbackthis in a .cjs file (sloppy)this in an .mjs file (strict)
setTimeout, setIntervalthe Timeout objectthe Timeout object
setImmediatethe Immediate objectthe Immediate object
emitter.on(...) listenerthe emitterthe emitter
process.nextTick, queueMicrotaskglobalThisundefined
promise .then / .catchglobalThisundefined
fs.readFile callbackglobalThisundefined
any arrow functionouter this (module.exports at the top level of a CJS file)outer this (undefined at the top level of a module)

EventEmitter is the one where Node deliberately gives you something useful: a regular-function listener gets the emitter as this. An arrow listener doesn't:

emitter-this.cjs
const { EventEmitter } = require("node:events");

const emitter = new EventEmitter();
emitter.on("event", function () {
  console.log("regular: this === emitter?", this === emitter); // true
});
emitter.on("event", () => {
  console.log("arrow: this === emitter?", this === emitter); // false
  console.log("arrow: this === module.exports?", this === module.exports); // true
});
emitter.emit("event");
What’s happening
  1. emit("event") calls each listener synchronously, in registration order, and calls them with the emitter as this (internally, roughly listener.apply(emitter, args)).
  2. The regular function accepts that, so this === emitter is true. This lets one shared handler function serve many emitters and know which one fired.
  3. An arrow function ignores the this it's called with. It uses the this of the scope it was written in — the top level of a CommonJS module, where this is module.exports.
  4. Choosing an arrow over a regular function isn't just style here: it changes what this means. More on emitters in Emitting and listening.
The silent listener bug

What does this print?

listener-bug.cjs
const { EventEmitter } = require("node:events");

class OrderCounter {
  constructor(source) {
    this.count = 0;
    source.on("order", this.handle);
  }
  handle() {
    this.count++;
  }
}

const orders = new EventEmitter();
const counter = new OrderCounter(orders);
orders.emit("order");
orders.emit("order");
console.log("counter.count =", counter.count, "| orders.count =", orders.count);
Show answer

counter.count = 0 | orders.count = NaN — and no error is thrown.

What’s happening
  1. source.on("order", this.handle) registers the bare handle function; nothing records that it came from counter.
  2. On each emit, the emitter calls handle with the emitter as this. So this.count++ reads orders.count (undefined), adds one (NaN), and creates a count property on the emitter.
  3. counter.count is never touched and stays 0. Unlike a detached class method called plainly (where this is undefined and you'd get a TypeError), this bug is silent, because the emitter supplies a real object.
  4. Fix it with source.on("order", () => this.handle()) or source.on("order", this.handle.bind(this)). To be able to remove the listener later, keep the bound function in a property (this.onOrder = this.handle.bind(this)) and pass that same reference to off.

Built from Rohit’s “Nodejs interview study” and “Nodejs-Interview-Chatgpt” docs. Examples are run on Node 26.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.