Callbacks, Promises & async/await

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-03, and every example below was run on all three lines unless the text says otherwise. Asynchronous code is how Node.js serves many connections from one main thread: a call that has to wait for the file system, the network or a timer returns immediately and delivers its result later. There have been three generations of syntax for "later" (callbacks, promises, async/await) and all three are still in daily use, because the built-in APIs started with callbacks and the ecosystem is a mixture. This page covers each generation, how to convert between them, the promise combinators, the flow-control patterns that replace the old async library, and what happens when a rejection nobody handles reaches the top. For the language-level treatment of the same features read Asynchronous JavaScript and Iterators and Generators; this page concentrates on what Node.js adds.

Stability matters because the page mixes two kinds of API. Promise, async functions, for await, top-level await, Promise.withResolvers() and Array.fromAsync() are ECMAScript language features supplied by V8 (V8 12.4 on Node.js 22, 13.6 on 24 and 14.6 on 26); they are not covered by the Node.js Stability Index. The Node.js APIs around them are Stability 2 (Stable), apart from the two marked Experimental in the table below. The event loop mechanics behind all of this are in The Event Loop, the timer functions in Timers, cancelling asynchronous work in Async Context & Cancellation, and the policy for crashing, logging and restarting in Errors & Error Handling. The running scenario is the Bookshelf application that grows across this section.

The stability and version map

Table 1. Features covered on this page, their stability and the versions that matter
Feature Stability Version notes

Error-first callbacks

A convention, not an API

The signature used by every callback-style API in the core modules.

Promise, async/await, for await

ECMAScript language features

Available on all supported lines.

Top-level await

ECMAScript language feature (modules only)

ES modules only; CommonJS files cannot use it. Stable in Node.js since v14.8.0.

Promise.withResolvers(), Array.fromAsync()

ECMAScript language features

Available on Node.js 22.23.3, 24.21.0 and 26.10.0 (checked).

Promise.try()

ECMAScript language feature

Available on Node.js 24.21.0 and 26.10.0; not available on 22.23.3 (checked, V8 12.4).

util.promisify(), util.callbackify()

No marker in the util page (the module is Stability 2, Stable)

Added in v8.0.0 and v8.2.0. Calling promisify on a function that already returns a promise is deprecated (DEP0174): documentation-only in v20.8.0, runtime deprecation in v21.0.0.

node:fs/promises, node:stream/promises, node:dns/promises, node:timers/promises

2 Stable

fs/promises and dns/promises exposed as node: modules in v14.0.0 and v15.0.0; timers/promises graduated from experimental in v16.0.0.

node:readline/promises

Stable since v24.0.0 and v22.17.0

Added in v17.0.0; earlier 22.x releases still labelled it experimental.

node:inspector/promises

1 Experimental

Added in v19.0.0. Mentioned only to complete the family.

process.nextTick()

3 Legacy

Marked Legacy in v22.7.0 and v20.18.0; the docs say to prefer queueMicrotask().

queueMicrotask()

Part of the Global objects page (Stable)

Added in v11.0.0.

process events 'unhandledRejection' and 'rejectionHandled'

No marker

Added in v1.4.1. Default behaviour changed to throw in v15.0.0.

--unhandled-rejections=mode

No marker

Added in v12.0.0 and v10.17.0; default mode changed to throw in v15.0.0.

util.markPromiseAsHandled()

No marker

Added in v26.10.0; not available on Node.js 24.21.0 or 22.23.3 (checked).

readable.map() with concurrency

1 Experimental

Added in v17.4.0 and v16.14.0 and still Experimental.

scheduler.wait(), scheduler.yield()

1 Experimental

Added in v17.3.0 and v16.14.0; see Timers.

Error-first callbacks

A callback is a function you hand to another function to be called when the work is done. Node.js standardised the shape early, and it is still the signature of every callback-style API in the core modules: the callback is the last argument, its first parameter is the error (or null), and the remaining parameters carry the results. When the operation succeeds the first argument is null (or undefined) and the rest are meaningful; when it fails the first argument is an Error and nothing else should be trusted. This is the "error-first" or "Node.js-style" callback. The convention means that one rule covers every API: check err first, return after handling it, and only then touch the results.

An error-first callback API and its consumers (cb1.mjs)
import { readFile } from 'node:fs';

function countBooks(path, callback) {
  readFile(path, 'utf8', (err, text) => {
    if (err) {
      callback(err); // error first; no second argument
      return;
    }
    const rows = text.trim().split('\n').slice(1);
    callback(null, rows.length);
  });
}

countBooks('data.csv', (err, n) => {
  if (err) throw err;
  console.log('books:', n);
});

countBooks('missing.csv', (err, n) => {
  console.log(err.code, err.syscall, n);
});

// A try/catch around the call cannot see an error that is delivered later.
try {
  readFile('missing.csv', (err) => {
    console.log('delivered to the callback:', err.code);
  });
  console.log('the try block has already finished');
} catch {
  console.log('never reached');
}

Output on Node.js 26 (identical on 24 and 22; the order of the last three lines can differ between runs because they come from independent file reads):

the try block has already finished
ENOENT open undefined
delivered to the callback: ENOENT
books: 2

Two properties of this style are the source of most callback bugs. First, the callback runs later, from the event loop, after the function that started the work has returned. A try/catch around the call therefore cannot catch an error that the operation reports through its callback, as the last part of the example shows; the error travels through the err argument or it is lost. Second, nothing in the language stops a function from calling its callback twice, or never, or both synchronously and asynchronously. The next two sections are about the consequences.

Callback hell

When each step needs the result of the previous one, callbacks nest. Three steps produce a pyramid whose indentation grows with the number of steps, and every level needs its own if (err) check; forgetting one swallows an error silently. The pattern is called callback hell, or the pyramid of doom. The first defence needs no new syntax: give each step a name and pass the function instead of writing it inline, so the code reads top to bottom again.

The same three steps nested and flattened with named functions (cb2.mjs)
import { readFile, writeFile } from 'node:fs';

// Nested ("pyramid") style: every step is one level deeper
readFile('data.csv', 'utf8', (err, text) => {
  if (err) return console.error(err);
  const upper = text.toUpperCase();
  writeFile('out-upper.csv', upper, (err) => {
    if (err) return console.error(err);
    readFile('out-upper.csv', 'utf8', (err, copy) => {
      if (err) return console.error(err);
      console.log('nested:', copy.split('\n')[1]);
    });
  });
});

// Flattened with named functions: same behaviour, one level
function step1() {
  readFile('data.csv', 'utf8', step2);
}
function step2(err, text) {
  if (err) return console.error(err);
  writeFile('out-upper2.csv', text.toUpperCase(), step3);
}
function step3(err) {
  if (err) return console.error(err);
  readFile('out-upper2.csv', 'utf8', step4);
}
function step4(err, copy) {
  if (err) return console.error(err);
  console.log('flat:', copy.split('\n')[1]);
}
step1();

Both versions print the same line (the two chains run concurrently, so their order depends on the thread pool):

flat: DUNE,HERBERT,1965
nested: DUNE,HERBERT,1965

Flattening fixes readability but not the structural problems: error handling is still repeated at every step, running steps in parallel or in a loop needs hand-written counters, and sharing state between the steps needs variables in an outer scope. Promises (next sections) remove these costs, and the Learn article names promises and async/await as the alternatives to callbacks.

Zalgo: callbacks that are sometimes synchronous

A function that takes a callback must call it either always synchronously or always asynchronously. An API that is synchronous on one path (a cache hit, an argument check) and asynchronous on another (a cache miss, a file read) is nicknamed "releasing Zalgo": the caller cannot know whether code written after the call has already run when the callback fires. The example makes the difference visible: the caller sets ready = true right after the call and the callback reports what it saw.

An inconsistent callback (Zalgo) and a consistent one (zalgo.mjs)
import { readFile } from 'node:fs';

const cache = new Map();

// Inconsistent: synchronous on a cache hit, asynchronous on a miss
function getTitleZalgo(path, callback) {
  if (cache.has(path)) {
    callback(null, cache.get(path)); // sync
    return;
  }
  readFile(path, 'utf8', (err, text) => {
    if (err) return callback(err);
    const title = text.split('\n')[1].split(',')[0];
    cache.set(path, title);
    callback(null, title); // async
  });
}

// Consistent: always asynchronous
function getTitle(path, callback) {
  if (cache.has(path)) {
    queueMicrotask(() => callback(null, cache.get(path)));
    return;
  }
  readFile(path, 'utf8', (err, text) => {
    if (err) return callback(err);
    const title = text.split('\n')[1].split(',')[0];
    cache.set(path, title);
    callback(null, title);
  });
}

function demo(label, fn, then) {
  let ready = false;
  fn('data.csv', () => {
    console.log(label, 'callback ran with ready =', ready);
  });
  ready = true; // the caller assumes the callback has not run yet
  if (then) setTimeout(then, 20);
}

demo('zalgo, first call (miss):', getTitleZalgo, () => {
  demo('zalgo, second call (hit): ', getTitleZalgo, () => {
    demo('fixed, third call (hit): ', getTitle);
  });
});

Output on Node.js 26 (identical on 24 and 22):

zalgo, first call (miss): callback ran with ready = true
zalgo, second call (hit):  callback ran with ready = false
fixed, third call (hit):  callback ran with ready = true

The second line is the bug: on a cache hit the callback ran before the caller’s next statement, so ready was still false. The fix is to defer the synchronous path, and there are three tools for it. queueMicrotask() runs the callback after the current operation, before the event loop continues, and is what the Node.js documentation recommends. setImmediate() defers to the check phase of the event loop. process.nextTick() has been Stability 3 (Legacy) since v22.7.0 and v20.18.0, so older tutorials that fix Zalgo with it still work but are not what new code should use. Their relative ordering is explained in The Event Loop. The cleaner solution is not to use callbacks for such an API at all: a promise is always delivered asynchronously, so a function that returns one cannot release Zalgo.

Promises: states, chaining and ordering

A promise is an object that stands for a value that is not available yet. It is in one of three states: pending, fulfilled with a value or rejected with a reason. A promise settles once: after the first call to resolve or reject every later call is ignored, and a settled promise never changes state. You subscribe with .then(onFulfilled), .catch(onRejected) and .finally(onSettled), and each of them returns a new promise, which is what makes chaining work. The rules of a chain are few. A value returned from a handler fulfils the next promise with that value. A promise returned from a handler is adopted: the chain waits for it. A thrown error rejects the next promise. A .catch() that returns normally turns the chain back into the fulfilled state, and .finally() runs either way without changing the outcome unless it throws.

Chaining, catching and recovering (p1.mjs)
import { readFile } from 'node:fs/promises';

const p = readFile('data.csv', 'utf8');
console.log('right after the call:', p);

readFile('data.csv', 'utf8')
  .then((text) => text.trim().split('\n').slice(1))      // returns a value
  .then((rows) => rows.map((row) => row.split(',')))     // returns a value
  .then(([[title]]) => readFile(`${title.toLowerCase()}.txt`, 'utf8')) // returns a promise: the chain waits
  .then((never) => console.log('not reached', never))
  .catch((err) => {
    console.log('caught:', err.code);
    return 'recovered';                                  // the chain continues as fulfilled
  })
  .then((value) => console.log('after catch:', value))
  .finally(() => console.log('finally runs either way'));

// A promise settles once; later calls are ignored
new Promise((resolve, reject) => {
  resolve('first');
  resolve('second');
  reject(new Error('ignored'));
}).then((v) => console.log('settled with', v));

Output on Node.js 26 (identical on 24 and 22):

right after the call: Promise { <pending> }
settled with first
caught: ENOENT
after catch: recovered
finally runs either way

The promise is pending immediately after the call, and the one-shot promise settled with first before the readFile chain finished, because the file read takes a trip through the thread pool. A rejected readFile skipped the then that would have used its value and landed in the first .catch(), which is the error-propagation behaviour that callbacks need an if at every level for.

Creating promises

Most code consumes promises returned by APIs, but you create them when wrapping something that is neither promise- nor async-based. There are three ways. The executor form, new Promise((resolve, reject) ⇒ …​), runs its function immediately and is right for wrapping a single callback. Promise.withResolvers() returns the promise together with its resolve and reject functions, which is convenient when the code that settles it lives somewhere else, such as an event handler. Promise.try(fn) runs fn synchronously and returns a promise whether fn returned a value, returned a promise or threw. The Learn article on promises lists Promise.try and Promise.withResolvers with the other advanced methods. Anything with a then method (a "thenable") is assimilated by await and by promise resolution.

Executor, withResolvers, try and a thenable (p2.mjs)
// Executor, withResolvers and try: three ways to create a promise
const wait = (ms, value) =>
  new Promise((resolve) => setTimeout(resolve, ms, value));

console.log(await wait(10, 'executor'));

const { promise, resolve } = Promise.withResolvers();
setTimeout(resolve, 10, 'withResolvers');
console.log(await promise);

console.log(typeof Promise.try);
if (typeof Promise.try === 'function') {
  const r = await Promise.try(() => JSON.parse('{"a":1}'));
  console.log('try:', r);
  await Promise.try(() => JSON.parse('{')).catch((e) => console.log('try caught', e.name));
}

// Thenables are assimilated
const thenable = { then(res) { res('from a thenable'); } };
console.log(await thenable);

Output on Node.js 26 and 24:

executor
withResolvers
function
try: { a: 1 }
try caught SyntaxError
from a thenable

On Node.js 22.23.3 Promise.try is undefined, so the output prints undefined instead of function, skips the two try lines and still prints the thenable; code that must run on 22 should use new Promise or an async wrapper instead.

When promise handlers run

A promise reaction (the function passed to .then(), the code after an await) never runs synchronously: it is queued as a microtask and runs when the current operation finishes and the call stack is empty, before the event loop moves on to timers or I/O. queueMicrotask() uses the same queue. process.nextTick() has a queue of its own, which Node.js drains before the microtask queue between operations, with a consequence that surprises people: whether a pending nextTick callback runs before or after the promise reactions depends on whether the code is a CommonJS or an ES module, because an ES module’s first evaluation is itself running inside a promise job.

The same script as a CommonJS file and as an ES module (order.cjs and order.mjs)
process.nextTick(() => console.log('process.nextTick'));
queueMicrotask(() => console.log('queueMicrotask'));
Promise.resolve().then(() => console.log('promise.then'));

(async () => {
  console.log('async function body runs synchronously');
  await null;
  console.log('continuation after await');
})();

console.log('end of the script');

The code is identical in both files. Output as order.cjs on Node.js 26, then as order.mjs (the same on 24 and 22):

async function body runs synchronously
end of the script
process.nextTick
queueMicrotask
promise.then
continuation after await

async function body runs synchronously
end of the script
queueMicrotask
promise.then
continuation after await
process.nextTick

An async function runs synchronously until its first await, as the first line shows; everything after the await is a microtask. Do not write code that depends on the position of nextTick relative to promise reactions; it is Legacy for that reason among others.

util.promisify and util.callbackify

Most of the callback API surface of the core modules can be converted mechanically. util.promisify(original) takes a function that follows the error-first convention, with the callback as the last argument, and returns a function that returns a promise: a non-null first callback argument rejects, otherwise the second argument fulfils. Some functions need more than the mechanical translation (a callback with several result arguments, for example), so the original function may carry a custom variant under the symbol util.promisify.custom, which promisify returns instead. The core library does this for setTimeout() and setImmediate(), whose promisified forms are the timers/promises functions, and for child_process.exec() and execFile(), whose promisified forms resolve with an object holding stdout and stderr. util.callbackify(original) goes the other way: it takes an async function (or one that returns a promise) and returns an error-first callback function, which is useful at the border with an older API that insists on a callback. If the promise rejects with a falsy value, the callback receives an error with the code ERR_FALSY_VALUE_REJECTION and the original value in its reason property, so that the first argument is never falsy on failure.

promisify, a custom promisified variant and callbackify (pf1.mjs)
import { promisify, callbackify } from 'node:util';
import { readFile } from 'node:fs';
import { setTimeout as sleep } from 'node:timers/promises';
import { execFile } from 'node:child_process';
import timers from 'node:timers';

// 1. promisify a standard error-first function
const readFileP = promisify(readFile);
console.log((await readFileP('data.csv', 'utf8')).split('\n')[0]);

// 2. functions with a custom promisified variant return something more useful
console.log(promisify(timers.setTimeout) === sleep);
const execFileP = promisify(execFile);
const { stdout } = await execFileP(process.execPath, ['-p', '1 + 1']);
console.log('execFile resolves an object:', stdout.trim());

// 3. a hand-written custom variant for a callback with a non-standard shape
function legacyLookup(id, onOk, onFail) {
  setImmediate(() => (id === 1 ? onOk({ id, title: 'Dune' }) : onFail(new Error('no such id'))));
}
legacyLookup[promisify.custom] = (id) =>
  new Promise((resolve, reject) => legacyLookup(id, resolve, reject));
const lookup = promisify(legacyLookup);
console.log(await lookup(1));
await lookup(2).catch((e) => console.log(e.message));

// 4. callbackify: an async function for code that wants a callback
async function titleOf(id) {
  if (id !== 1) throw new Error('no such id');
  return 'Dune';
}
const titleOfCb = callbackify(titleOf);
titleOfCb(1, (err, title) => console.log('callbackify ok:', err, title));
titleOfCb(2, (err, title) => console.log('callbackify err:', err.message, title));

// 5. callbackify wraps a falsy rejection in ERR_FALSY_VALUE_REJECTION
callbackify(() => Promise.reject(null))((err) => console.log(err.code, err.reason));

Output on Node.js 26 (identical on 24 and 22):

title,author,year
true
execFile resolves an object: 2
{ id: 1, title: 'Dune' }
no such id
callbackify ok: null Dune
callbackify err: no such id undefined
ERR_FALSY_VALUE_REJECTION null

promisify is for callback functions only. Calling it on a function that already returns a promise is almost always a mistake: the wrapper passes its own callback as an extra last argument, which the original ignores, so the returned promise never settles. Node.js warns about this with DEP0174 (documentation-only since v20.8.0, a runtime deprecation since v21.0.0).

The DEP0174 mistake: promisify applied to a promise-returning function (pf2.mjs)
import { promisify } from 'node:util';
import { readFile } from 'node:fs/promises';

// DEP0174: promisify() of a function that already returns a promise
const bad = promisify(readFile);
const p = bad('data.csv', 'utf8');
console.log(await Promise.race([p, new Promise((r) => setTimeout(r, 200, 'still pending after 200 ms'))]));

Output on Node.js 26 and 22 (the same warning is printed by 24):

(node:12345) [DEP0174] DeprecationWarning: Calling promisify on a function that returns a Promise is likely a mistake.
still pending after 200 ms

The /promises module variants

Because converting by hand is tedious, the core modules that grew up on callbacks now ship promise versions of their APIs, usually importable in two equivalent ways: from the node: module with the /promises suffix, or from a promises property of the main module. In the fs case import fs from 'node:fs'; fs.promises and import fsp from 'node:fs/promises' are the same object (checked). Prefer the first-class node:fs/promises form in new code; it imports only what you use. The promise variants also settle some conventions that the callback forms leave open: no callback is passed and the function throws or rejects consistently, and results are returned instead of being passed as extra arguments.

Table 2. The promise variants of the core modules covered in this section
Module Stability What it offers

node:fs/promises

2 Stable

The whole file API, including FileHandle objects. See File System.

node:timers/promises

2 Stable

setTimeout, setImmediate, async-iterable setInterval, scheduler.wait/scheduler.yield (Experimental). See Timers.

node:stream/promises

2 Stable

pipeline() and finished() that return promises. See Streams.

node:dns/promises

2 Stable

lookup, resolve* and a Resolver class.

node:readline/promises

Stable since v24.0.0 and v22.17.0

question() that returns a promise. See Console, readline & Terminal I/O.

node:inspector/promises

1 Experimental

A promise version of the inspector session API.

node:stream/consumers

No marker (the stream module is Stable)

Helpers such as text(), json() and buffer() that read a whole stream into a promise.

Functions in these modules reject with the same error objects (error.code such as ENOENT) that the callback forms pass to the callback, so the error handling you know carries over. APIs that are not in the table either offer promises elsewhere (global fetch() returns a promise, see fetch & HTTP Clients) or still need util.promisify.

Five of the variants in one script (pv.mjs)
import { readFile, stat } from 'node:fs/promises';
import { setTimeout as sleep } from 'node:timers/promises';
import { pipeline } from 'node:stream/promises';
import { lookup } from 'node:dns/promises';
import { createInterface } from 'node:readline/promises';
import { Readable, Writable } from 'node:stream';

console.log((await stat('data.csv')).size, 'bytes');
console.log(await sleep(20, 'timers/promises resolves with the value'));

const chunks = [];
await pipeline(
  Readable.from(['a', 'b', 'c']),
  new Writable({ write(chunk, _enc, done) { chunks.push(chunk.toString()); done(); } }),
);
console.log('stream/promises pipeline done:', chunks.join(''));

console.log('dns/promises:', (await lookup('localhost', { family: 4 })).address);
console.log('readline/promises:', typeof createInterface);

Output on Node.js 26 (identical on 24 and 22):

53 bytes
timers/promises resolves with the value
stream/promises pipeline done: abc
dns/promises: 127.0.0.1
readline/promises: function

Promise.all, allSettled, any and race

Four static methods take an iterable of promises (non-promise values are treated as already fulfilled) and return one promise. They differ in what they wait for and in what they do with failures, and choosing the right one is most of the skill of running asynchronous work concurrently.

Table 3. The four promise combinators
Method Fulfils Rejects Use it when

Promise.all(iterable)

When every input has fulfilled, with an array of the values in input order.

As soon as one input rejects, with that reason. The other promises keep running; their results are discarded.

You need every result and one failure invalidates the whole operation.

Promise.allSettled(iterable)

When every input has settled, with an array of status records (fulfilled with value, rejected with reason).

Never.

You want to try everything and report partial failure.

Promise.any(iterable)

As soon as one input fulfils, with its value.

When all inputs reject, with an AggregateError whose errors array holds the reasons (also for an empty iterable).

Any one successful source is enough, such as the same data from mirrors.

Promise.race(iterable)

As soon as one input settles with a value.

As soon as one input settles with a rejection.

A timeout, or the first answer whatever it is. An empty iterable never settles.

The four combinators with fulfilling and rejecting inputs (comb.mjs)
import { setTimeout as sleep } from 'node:timers/promises';

const ok = (ms, v) => sleep(ms, v);
const fail = (ms, msg) => sleep(ms).then(() => { throw new Error(msg); });

// all: fulfils with an array in input order; rejects with the first rejection
console.log(await Promise.all([ok(30, 'a'), ok(10, 'b'), 3]));
await Promise.all([ok(30, 'a'), fail(10, 'b failed'), fail(20, 'c failed')])
  .catch((e) => console.log('all rejected with:', e.message));

// allSettled: never rejects; one record per input
const settled = await Promise.allSettled([ok(10, 'a'), fail(10, 'b failed')]);
console.log(settled.map((r) => (r.status === 'fulfilled' ? r.value : r.reason.message)), settled.map((r) => r.status));

// any: first fulfilment; AggregateError when every input rejects
console.log(await Promise.any([fail(10, 'x'), ok(30, 'y'), ok(50, 'z')]));
await Promise.any([fail(10, 'x'), fail(20, 'y')]).catch((e) =>
  console.log(e.constructor.name, e.message, e.errors.map((x) => x.message)));

// race: first settlement wins, fulfilled or rejected
console.log(await Promise.race([ok(30, 'slow'), ok(10, 'fast')]));
await Promise.race([ok(30, 'slow'), fail(10, 'fast failure')])
  .catch((e) => console.log('race rejected with:', e.message));

// the empty cases
console.log(await Promise.all([]), await Promise.allSettled([]));
Promise.any([]).catch((e) => console.log('any([]) ->', e.constructor.name));

Output on Node.js 26 (identical on 24 and 22):

[ 'a', 'b', 3 ]
all rejected with: b failed
[ 'a', 'b failed' ] [ 'fulfilled', 'rejected' ]
y
AggregateError All promises were rejected [ 'x', 'y' ]
fast
race rejected with: fast failure
[] []
any([]) -> AggregateError

Two details deserve attention. Promise.all and the other combinators attach handlers to every input, so a second rejection (c failed above) is not reported as an unhandled rejection even though the combined promise only carries the first reason. And none of them cancels the losers: Promise.race against a timeout stops waiting, but the slow operation keeps running unless you pass it an AbortSignal, which Async Context & Cancellation covers.

Why await [p1, p2] is not Promise.all

An array is not a promise and has no then method, so await on an array does nothing except hand the same array back on the next microtask. The promises inside keep running, nobody waits for them, and nobody observes their failures. It looks plausible next to await Promise.all([p1, p2]), which is the reason it keeps appearing in tutorials, and it fails silently. There is no runtime warning.

await on an array compared with Promise.all (notall.mjs)
import { setTimeout as sleep } from 'node:timers/promises';

const p1 = sleep(30, 'one');
const p2 = sleep(10, 'two');

// await on an array is not Promise.all: an array is not a thenable
const wrong = await [p1, p2];
console.log(wrong);                      // the same two Promise objects
console.log(wrong[0] instanceof Promise);

const right = await Promise.all([p1, p2]);
console.log(right);

// a rejected promise inside the array is not observed either
const lost = Promise.reject(new Error('nobody awaits me'));
await [lost];
await sleep(10);
console.log('not reached when rejections are fatal');

The first three lines show the array coming back untouched and then the correct result. The last statement shows the cost: the rejection inside the array was never observed, so the process dies with the default unhandled-rejection behaviour, covered below. Output on Node.js 26 (paths abridged; the same on 24 and 22 apart from the version line):

[ Promise { <pending> }, Promise { <pending> } ]
true
[ 'one', 'two' ]
file:///.../notall.mjs:15
const lost = Promise.reject(new Error('nobody awaits me'));
                            ^

Error: nobody awaits me

Node.js v26.10.0

async/await

An async function always returns a promise: a returned value fulfils it, a thrown error rejects it. Inside it, await suspends the function until a promise settles and then either produces its value or throws its rejection reason, so the ordinary try, catch and finally statements, loops and early returns work on asynchronous code. Nothing else changes: it is promises underneath, which is why the two styles mix freely. Three habits make the difference between correct and merely plausible code.

  • await in a loop runs the iterations one after the other. That is what you want when each step depends on the previous one, and a slowdown of n times when the iterations are independent; for those use Promise.all (see flow control).

  • forEach, map and the other array methods ignore the promise that an async callback returns. forEach does not wait at all, and map returns an array of promises that you must pass to Promise.all. Use for…​of or Promise.all(array.map(…​)).

  • Inside try, return somePromise hands the promise to the caller without waiting, so a rejection bypasses your catch. Use return await somePromise when the catch or finally must see the outcome.

async functions, try/catch, sequential versus concurrent and the forEach trap (aw1.mjs)
import { readFile } from 'node:fs/promises';
import { setTimeout as sleep } from 'node:timers/promises';

// An async function always returns a promise, even when it returns a plain value
async function answer() { return 42; }
console.log(answer() instanceof Promise, await answer());

// A throw inside becomes a rejection; try/catch around await sees it
async function load(path) {
  try {
    return JSON.parse(await readFile(path, 'utf8'));
  } catch (err) {
    console.log(`load(${path}) failed:`, err.code ?? err.name);
    return null;
  }
}
console.log(await load('missing.json'));

// Sequential vs concurrent: same work, different elapsed time
const fetchBook = async (id) => { await sleep(100); return { id }; };
const time = async (label, fn) => {
  const t = performance.now();
  const r = await fn();
  console.log(label, Math.round((performance.now() - t) / 50) * 50, 'ms ->', r.length, 'books');
};
const ids = [1, 2, 3, 4];

await time('sequential:', async () => {
  const out = [];
  for (const id of ids) out.push(await fetchBook(id));
  return out;
});
await time('concurrent:', () => Promise.all(ids.map(fetchBook)));

// forEach does not wait for async callbacks
const seen = [];
ids.forEach(async (id) => { await fetchBook(id); seen.push(id); });
console.log('after forEach:', seen.length, 'done');
await sleep(150);
console.log('later:', seen.length, 'done');

Output on Node.js 26 (identical on 24 and 22; the timings are rounded to 50 ms):

true 42
load(missing.json) failed: ENOENT
null
sequential: 400 ms -> 4 books
concurrent: 100 ms -> 4 books
after forEach: 0 done
later: 4 done
return versus return await inside try (aw2.mjs)
import { setTimeout as sleep } from 'node:timers/promises';

async function inner() { await sleep(5); throw new Error('boom'); }

async function withoutAwait() {
  try { return inner(); }            // the promise escapes the try block
  catch { return 'caught here'; }
}
async function withAwait() {
  try { return await inner(); }      // the rejection is thrown inside the try block
  catch { return 'caught here'; }
}
console.log(await withAwait());
console.log(await withoutAwait().catch((e) => 'caught by the caller: ' + e.message));

Output (identical on Node.js 26, 24 and 22):

caught here
caught by the caller: boom

for await and async iteration

for await (const x of source) consumes an async iterable: an object whose Symbol.asyncIterator method returns an iterator that yields promises. Each loop turn waits for the next value, a break or a throw closes the source, and an error from the source is thrown at the loop. In Node.js the important async iterables are all the Readable streams (chunk by chunk), readline interfaces (line by line), events.on() (event by event), and timers/promises setInterval() (tick by tick). You write your own with an async generator (async function*), where await and yield can be combined. Array.fromAsync() collects an async iterable into an array. The language side is covered in Iterators and Generators; the stream-specific rules (backpressure, cleanup) are in Streams.

Streams, readline, an async generator, Array.fromAsync, timers and events as async iterables (fa.mjs)
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
import { setInterval as ticks } from 'node:timers/promises';
import { once, on, EventEmitter } from 'node:events';

// 1. a Readable stream is an async iterable of chunks
let bytes = 0;
for await (const chunk of createReadStream('data.csv')) bytes += chunk.length;
console.log('bytes read:', bytes);

// 2. readline gives lines; crlfDelay: Infinity treats \r\n as one line break
const rl = createInterface({ input: createReadStream('data.csv'), crlfDelay: Infinity });
for await (const line of rl) console.log('line:', line);

// 3. an async generator is the producer side of for await
async function* books() {
  for await (const line of createInterface({ input: createReadStream('data.csv') })) {
    if (line.startsWith('title')) continue;
    const [title, author, year] = line.split(',');
    yield { title, author, year: Number(year) };
  }
}
for await (const b of books()) console.log(b.title, b.year);
console.log(await Array.fromAsync(books(), (b) => b.title));

// 4. timers/promises setInterval is an async iterator; break stops it
let n = 0;
for await (const _ of ticks(20)) { if (++n === 3) break; }
console.log('ticks:', n);

// 5. events.on turns an emitter into an async iterator
const ee = new EventEmitter();
setImmediate(() => { ee.emit('book', 'Dune'); ee.emit('book', 'Emma'); ee.emit('end'); });
const ac = new AbortController();
once(ee, 'end').then(() => ac.abort());
try {
  for await (const [title] of on(ee, 'book', { signal: ac.signal })) console.log('event:', title);
} catch (e) {
  console.log(e.name);
}

Output on Node.js 26 (identical on 24 and 22):

bytes read: 53
line: title,author,year
line: Dune,Herbert,1965
line: Emma,Austen,1815
Dune 1965
Emma 1815
[ 'Dune', 'Emma' ]
ticks: 3
event: Dune
event: Emma
AbortError

The events.on() loop in the last block never finishes by itself, so the example aborts it with an AbortSignal after the emitter says it is done; the loop then throws an AbortError, which is the normal way to end it.

Top-level await

In an ES module await may be used outside a function. The module is then asynchronous: it starts evaluating, suspends at the await, and the modules that import it wait until it settles, while unrelated modules keep evaluating. That is useful for one-time initialisation (reading a configuration file, opening a connection) and a trap when the awaited work is slow, because it delays everything that depends on the module. Top-level await exists only in ES modules: in a CommonJS file it is a SyntaxError. require() of an ES module works since v22.12.0 and is Stability 2 (Stable) since v25.4.0, but it cannot load a module that uses top-level await, and throws ERR_REQUIRE_ASYNC_MODULE (checked on 26, 24 and 22). A top-level await that can never settle makes the process exit with code 13 ("Unsettled Top-Level Await" in the exit-code table) and a warning, instead of hanging. Modules, import() and the CommonJS interop are covered in ECMAScript Modules and ESM & CommonJS Interop.

A module with top-level await, its importer, and the two failure modes (tla.mjs, tla-main.mjs, tla-req.cjs, unsettled.mjs)
// tla.mjs
import { readFile } from 'node:fs/promises';
const csv = await readFile(new URL('./data.csv', import.meta.url), 'utf8');
export const titles = csv.trim().split('\n').slice(1).map((r) => r.split(',')[0]);
console.log('tla module evaluated:', titles);

// tla-main.mjs
console.log('main starts');
const { titles } = await import('./tla.mjs');
console.log('main resumes:', titles.length);

// tla-req.cjs
try {
  require('./tla.mjs');
} catch (e) {
  console.log(e.code);
}

// unsettled.mjs
console.log("waiting"); await new Promise(() => {}); console.log("never");

Running node tla-main.mjs, node tla-req.cjs and node unsettled.mjs on Node.js 26 (identical on 24 and 22; the warning text of the last one is abridged, and its exit code is 13):

main starts
tla module evaluated: [ 'Dune', 'Emma' ]
main resumes: 2

ERR_REQUIRE_ASYNC_MODULE

waiting
Warning: Detected unsettled top-level await at file:///.../unsettled.mjs:1

Flow control: series, parallel and limited concurrency

Asynchronous flow control is deciding how many operations run at once and what to do with their results and failures. The Learn article on asynchronous flow control frames it as state management plus control flow, and the same three shapes cover nearly every case.

Table 4. The three flow-control shapes
Shape Tools Trade-off

Series (one after the other)

for…​of with await; promise chain with reduce

Slowest, but ordered and gentle on the target. Required when a step uses the result of the previous one.

Parallel (all at once)

Promise.all, Promise.allSettled, Promise.any

Fastest, but unbounded: a thousand inputs mean a thousand simultaneous file handles, sockets or queries, and the target (a database pool, a rate-limited API, the thread pool) becomes the bottleneck.

Limited concurrency

A fixed number of workers pulling from a shared iterator; or readable.map(fn, { concurrency })

Bounds the load and keeps most of the speed. The right default for "do this for every item" over unbounded input.

The limited-concurrency helper below is short enough to write inline. The trick is that every worker loops over the same iterator, so each item is handed to exactly one worker and a free worker immediately takes the next. The built-in alternative is the concurrency option of readable.map(), which is Stability 1 (Experimental) but needs no helper.

Series, parallel, limited concurrency, Readable.map and failure collection (flow.mjs)
import { setTimeout as sleep } from 'node:timers/promises';
import { Readable } from 'node:stream';

let running = 0;
let peak = 0;
const job = async (id) => {
  running++;
  peak = Math.max(peak, running);
  await sleep(40);
  running--;
  return id * 10;
};
const ids = [1, 2, 3, 4, 5, 6, 7, 8];

async function timed(label, fn) {
  running = 0; peak = 0;
  const t = performance.now();
  const out = await fn();
  console.log(`${label.padEnd(10)} ${String(Math.round((performance.now() - t) / 40) * 40).padStart(3)} ms  peak=${peak}  ${JSON.stringify(out)}`);
}

// Series: one at a time, results in order
await timed('series', async () => {
  const out = [];
  for (const id of ids) out.push(await job(id));
  return out;
});

// Parallel: everything at once (unbounded)
await timed('parallel', () => Promise.all(ids.map(job)));

// Limited concurrency: a fixed number of workers pulling from a shared iterator
async function mapLimit(items, limit, fn) {
  const results = new Array(items.length);
  const iterator = items.entries();
  async function worker() {
    for (const [index, item] of iterator) {
      results[index] = await fn(item);
    }
  }
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
  return results;
}
await timed('limit 3', () => mapLimit(ids, 3, job));

// The built-in alternative (Experimental): Readable.map with the concurrency option
await timed('stream x3', () => Readable.from(ids).map(job, { concurrency: 3 }).toArray());

// Fail fast vs collect everything
const flaky = async (id) => { await sleep(10); if (id % 4 === 0) throw new Error(`job ${id}`); return id; };
console.log(await Promise.allSettled(ids.map(flaky)).then((r) => r.filter((x) => x.status === 'rejected').map((x) => x.reason.message)));

Output on Node.js 26 (identical on 24 and 22; times are rounded to 40 ms, peak is the highest number of jobs running together):

series     320 ms  peak=1  [10,20,30,40,50,60,70,80]
parallel    40 ms  peak=8  [10,20,30,40,50,60,70,80]
limit 3    120 ms  peak=3  [10,20,30,40,50,60,70,80]
stream x3  120 ms  peak=3  [10,20,30,40,50,60,70,80]
[ 'job 4', 'job 8' ]

Series took the sum of the jobs, parallel took the longest one, and the limited version took ceil(8 / 3) rounds of one job. Results keep input order in all variants. For failure handling, Promise.all is fail-fast, while Promise.allSettled lets every job finish and reports the failures at the end; choose per use case. For jobs that are repeated and must survive restarts, a queue is the right tool (see Background Jobs, Scheduling & Queues), and for CPU-bound work more concurrency on the main thread does not help (see Don’t Block the Event Loop).

Unhandled rejections

A promise that rejects with no handler attached by the end of the turn is an unhandled rejection. Promises make this easy to do by accident: a forgotten await, a .then() without a .catch(), an async function passed where a plain callback is expected, or an array handed to await. Since Node.js 15.0.0 the default is to treat it like a thrown exception: the process prints the error and exits with code 1. The earlier behaviour (a warning, the process keeps running) is DEP0018, now End-of-Life, and survives only as one of the modes of --unhandled-rejections.

Crash by default (ur.mjs)
import { setTimeout as sleep } from 'node:timers/promises';

Promise.reject(new Error('nobody handled me'));
await sleep(50);
console.log('still running after the rejection');

Running node ur.mjs on Node.js 26 (identical on 24 and 22; stack frames omitted) prints the source line, the error and the version, and exits with code 1 without ever printing the still running line:

file:///.../ur.mjs:3
Promise.reject(new Error('nobody handled me'));
               ^

Error: nobody handled me
    at file:///.../ur.mjs:3:16

Node.js v26.10.0

The --unhandled-rejections=mode flag changes the policy. Exit codes below were measured by running ur.mjs on Node.js 26 and 22.

Table 5. The --unhandled-rejections modes
Mode Behaviour (from the CLI documentation) Measured with ur.mjs

throw (default)

Emit 'unhandledRejection'. If there is no listener, raise the rejection as an uncaught exception.

Crash, exit code 1.

strict

Raise the rejection as an uncaught exception. If that exception is handled (by an 'uncaughtException' listener), emit 'unhandledRejection'.

Crash, exit code 1.

warn

Always print a warning, whether or not a listener exists, without the deprecation warning.

UnhandledPromiseRejectionWarning printed, process keeps running, exit code 0.

warn-with-error-code

Emit 'unhandledRejection'. If there is no listener, print a warning and set the exit code to 1.

Warning printed, process keeps running, exit code 1.

none

Silence all warnings.

Nothing printed, exit code 0.

The documentation adds that a rejection during the static-loading phase of the entry-point ES module is always raised as an uncaught exception, whatever the mode. Use throw (the default) or strict in production and fix the cause, because a rejection nobody handled means the program is in a state the author did not plan for. The other modes are migration aids; none in particular hides real bugs. The policy question (log and exit, or log and keep running) and its relation to 'uncaughtException' are discussed in Errors & Error Handling.

The process events

A listener for 'unhandledRejection' receives the reason and the promise, and its presence changes the throw mode from "crash" to "call the listener". It is the place for a last log line and for a controlled shutdown. If a handler is attached to a promise after the event was emitted (more than one turn of the event loop later), 'rejectionHandled' fires, which lets a monitor retract its report.

Reacting to unhandledRejection and rejectionHandled (ur-handler.mjs)
import { setTimeout as sleep } from 'node:timers/promises';

process.on('unhandledRejection', (reason, promise) => {
  console.log('unhandledRejection:', reason.message, promise instanceof Promise);
});
process.on('rejectionHandled', (promise) => {
  console.log('rejectionHandled: a handler was attached late');
});

const late = Promise.reject(new Error('handled too late'));
await sleep(20);           // more than one turn of the event loop passes
late.catch(() => {});      // attached late: 'rejectionHandled' fires
await sleep(20);
console.log('still running');

Output on Node.js 26 (identical on 24 and 22); the process keeps running because a listener exists:

unhandledRejection: handled too late true
rejectionHandled: a handler was attached late
still running

A frequent source of unhandled rejections is passing an async function to an API that expects a plain callback and ignores its return value: setTimeout, an event listener, or a handler registered with a library that ignores promises. The rejection belongs to nobody and, with the default mode, takes the process down. Wrap such bodies in try/catch, or use an API that understands promises. For event emitters, captureRejections routes rejections of async listeners to the emitter’s 'error' event, see Events & EventEmitter.

An async timer callback has no one to report to (ur-cb.mjs)
// An async function passed where a plain callback is expected: its rejection has no owner
setTimeout(async () => {
  throw new Error('thrown inside an async timer callback');
}, 10);
setTimeout(() => console.log('this never prints under the default mode'), 100);

Running it crashes with exit code 1 and prints the error thrown inside the callback on Node.js 26, 24 and 22; the never prints line is never reached.

Node.js 26.10.0 added util.markPromiseAsHandled(promise), which marks a promise as handled so that its rejection is ignored and not reported. It is for the rare case where a rejection is intentionally never observed, for instance when a promise is created eagerly and may never be awaited. It is not available on 24.21.0 or 22.23.3, where the import fails with a SyntaxError.

Opting a single promise out of reporting, Node.js 26.10 and later (ur-mark.mjs)
import { markPromiseAsHandled } from 'node:util';
import { setTimeout as sleep } from 'node:timers/promises';

const p = Promise.reject(new Error('ignored on purpose'));
markPromiseAsHandled(p);
await sleep(50);
console.log('no crash');

On Node.js 26.10.0 it prints no crash and exits with code 0. On 24.21.0 and 22.23.3 the import fails with SyntaxError: The requested module 'node:util' does not provide an export named 'markPromiseAsHandled'; there, attach an empty .catch(() ⇒ {}) instead.

What changed recently

Table 6. Changes relevant to asynchronous code, with the versions checked on 2026-10-03
Change What it means for you Version

Unhandled rejections throw by default

A forgotten .catch() crashes the process instead of printing a warning. Tutorials from before this change describe the warning behaviour.

v15.0.0

util.promisify on a promise-returning function deprecated (DEP0174)

The wrapper never settles; Node.js warns at runtime.

Documentation-only v20.8.0, runtime v21.0.0

process.nextTick() marked Legacy

Use queueMicrotask() to defer work; nextTick remains for compatibility.

v22.7.0 and v20.18.0

node:readline/promises stable

Prompting with await rl.question() needs no promisify wrapper.

v24.0.0 and v22.17.0

require() of ES modules stable

Libraries can ship ES modules only, except when they use top-level await (ERR_REQUIRE_ASYNC_MODULE).

Unflagged v22.12.0, Stable v25.4.0

Promise.try() available

Start a chain from a function that may throw or return a value. Not on Node.js 22.

Node.js 24 and 26 (V8 13.6 and 14.6); absent on 22 (V8 12.4)

util.markPromiseAsHandled()

Intentional opt-out of unhandled-rejection reporting for one promise.

v26.10.0 (new in Current)

Promise.withResolvers() and Array.fromAsync() usable

Both run on every supported line (22, 24 and 26) without flags (checked).

Node.js 22.23.3, 24.21.0 and 26.10.0

Where the books are outdated

Asynchrony is the part of Node.js that the two reference books (Samer Buna, Efficient Node.js, O’Reilly, 2025, and Jonathan Wexler, Node.js Projects, O’Reilly, 2025) treat most thoroughly, so most of their material still holds: the error-first convention, the callback-to-promise-to-async progression and Promise.all fan-out are as described here. The corrections are about details and what is missing.

Table 7. Where the books fall behind or need correcting
Topic Current approach

Buna presents await [p1, p2] as equivalent to Promise.all

It is not: await on an array returns the array and observes nothing, see the section on await and arrays. Use await Promise.all([p1, p2]).

Buna names a module node:streams/promises (with an s)

The module is node:stream/promises; importing the misspelt name fails with ERR_UNKNOWN_BUILTIN_MODULE.

Wexler wraps readline with util.promisify, and the printed example uses an undefined rl

node:readline/promises returns promises natively and is Stable since v24.0.0 and v22.17.0; there is nothing to wrap. If you do promisify, the interface object must exist and the method must be bound to it.

Wexler’s RSS and API chapters fan out requests with unbounded Promise.all

Fine for a handful of feeds; for many items bound the concurrency (see flow control) and consider Promise.allSettled so one broken feed does not discard the rest.

Neither book covers the --unhandled-rejections modes or rejectionHandled

Unhandled rejections crash the process by default since v15.0.0; see Unhandled rejections.

Neither book covers Promise.withResolvers, Promise.try, Array.fromAsync or util.markPromiseAsHandled

All are in the version map above; the last is Node.js 26.10 and later only, Promise.try is absent on 22.

Zalgo fixes that use process.nextTick

process.nextTick is Legacy since v22.7.0; use queueMicrotask, or return a promise.

Callback and .then examples as the default style

async/await with the */promises modules is the default style today; util.promisify remains for third-party callback APIs.

References