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
| Feature | Stability | Version notes |
|---|---|---|
Error-first callbacks |
A convention, not an API |
The signature used by every callback-style API in the core modules. |
|
ECMAScript language features |
Available on all supported lines. |
Top-level |
ECMAScript language feature (modules only) |
ES modules only; CommonJS files cannot use it. Stable in Node.js since v14.8.0. |
|
ECMAScript language features |
Available on Node.js 22.23.3, 24.21.0 and 26.10.0 (checked). |
|
ECMAScript language feature |
Available on Node.js 24.21.0 and 26.10.0; not available on 22.23.3 (checked, V8 12.4). |
|
No marker in the |
Added in v8.0.0 and v8.2.0. Calling |
|
2 Stable |
|
|
Stable since v24.0.0 and v22.17.0 |
Added in v17.0.0; earlier 22.x releases still labelled it experimental. |
|
1 Experimental |
Added in v19.0.0. Mentioned only to complete the family. |
|
3 Legacy |
Marked Legacy in v22.7.0 and v20.18.0; the docs say to prefer |
|
Part of the Global objects page (Stable) |
Added in v11.0.0. |
|
No marker |
Added in v1.4.1. Default behaviour changed to throw in v15.0.0. |
|
No marker |
Added in v12.0.0 and v10.17.0; default mode changed to |
|
No marker |
Added in v26.10.0; not available on Node.js 24.21.0 or 22.23.3 (checked). |
|
1 Experimental |
Added in v17.4.0 and v16.14.0 and still Experimental. |
|
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.
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.
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();
Source: Node.js Learn: JavaScript Asynchronous Programming and Callbacks (The problem with callbacks)
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.
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.
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 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.
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.
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));
Source: Node.js API: util.promisify(original), util.callbackify(original), Child process (exec and execFile with util.promisify)
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).
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.
| Module | Stability | What it offers |
|---|---|---|
|
2 Stable |
The whole file API, including |
|
2 Stable |
|
|
2 Stable |
|
|
2 Stable |
|
|
Stable since v24.0.0 and v22.17.0 |
|
|
1 Experimental |
A promise version of the inspector session API. |
|
No marker (the |
Helpers such as |
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.
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);
Source: Node.js API: File system (Promises API), Timers (Timers Promises API), Stream (stream/promises)
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.
| Method | Fulfils | Rejects | Use it when |
|---|---|---|---|
|
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. |
|
When every input has settled, with an array of |
Never. |
You want to try everything and report partial failure. |
|
As soon as one input fulfils, with its value. |
When all inputs reject, with an |
Any one successful source is enough, such as the same data from mirrors. |
|
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. |
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.
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.
-
awaitin 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 usePromise.all(see flow control). -
forEach,mapand the other array methods ignore the promise that anasynccallback returns.forEachdoes not wait at all, andmapreturns an array of promises that you must pass toPromise.all. Usefor…oforPromise.all(array.map(…)). -
Inside
try,return somePromisehands the promise to the caller without waiting, so a rejection bypasses yourcatch. Usereturn await somePromisewhen thecatchorfinallymust see the outcome.
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
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.
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);
}
Source: Node.js API: Stream (Streams compatibility with async generators and async iterators), Events: events.on(emitter, eventName[, options)]
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.
// 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");
Source: Node.js Learn: Discover Promises in Node.js (Top-Level Await), Node.js API: ECMAScript modules
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.
| Shape | Tools | Trade-off |
|---|---|---|
Series (one after the other) |
|
Slowest, but ordered and gentle on the target. Required when a step uses the result of the previous one. |
Parallel (all at once) |
|
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 |
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.
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.
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.
| Mode | Behaviour (from the CLI documentation) | Measured with ur.mjs |
|---|---|---|
|
Emit |
Crash, exit code 1. |
|
Raise the rejection as an uncaught exception. If that exception is handled (by an |
Crash, exit code 1. |
|
Always print a warning, whether or not a listener exists, without the deprecation warning. |
|
|
Emit |
Warning printed, process keeps running, exit code 1. |
|
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.
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 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.
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
| Change | What it means for you | Version |
|---|---|---|
Unhandled rejections throw by default |
A forgotten |
v15.0.0 |
|
The wrapper never settles; Node.js warns at runtime. |
Documentation-only v20.8.0, runtime v21.0.0 |
|
Use |
v22.7.0 and v20.18.0 |
|
Prompting with |
v24.0.0 and v22.17.0 |
|
Libraries can ship ES modules only, except when they use top-level await ( |
Unflagged v22.12.0, Stable v25.4.0 |
|
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) |
|
Intentional opt-out of unhandled-rejection reporting for one promise. |
v26.10.0 (new in Current) |
|
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.
| Topic | Current approach |
|---|---|
Buna presents |
It is not: |
Buna names a module |
The module is |
Wexler wraps |
|
Wexler’s RSS and API chapters fan out requests with unbounded |
Fine for a handful of feeds; for many items bound the concurrency (see flow control) and consider |
Neither book covers the |
Unhandled rejections crash the process by default since v15.0.0; see Unhandled rejections. |
Neither book covers |
All are in the version map above; the last is Node.js 26.10 and later only, |
Zalgo fixes that use |
|
Callback and |
|
References
-
Node.js Learn, JavaScript Asynchronous Programming and Callbacks: https://nodejs.org/learn/asynchronous-work/javascript-asynchronous-programming-and-callbacks
-
Node.js Learn, Discover Promises in Node.js: https://nodejs.org/learn/asynchronous-work/discover-promises-in-nodejs
-
Node.js Learn, Asynchronous flow control: https://nodejs.org/learn/asynchronous-work/asynchronous-flow-control
-
Node.js API, util (
promisify,callbackify,markPromiseAsHandled): https://nodejs.org/docs/latest/api/util.html -
Node.js API, process (
'unhandledRejection','rejectionHandled',nextTick, exit codes): https://nodejs.org/docs/latest/api/process.html -
Node.js API, command-line options (
--unhandled-rejections): https://nodejs.org/docs/latest/api/cli.html -
Node.js API, deprecated APIs (DEP0018, DEP0174): https://nodejs.org/docs/latest/api/deprecations.html
-
Node.js API, child process (promisified
execandexecFile): https://nodejs.org/docs/latest/api/child_process.html -
Node.js API, global objects (
queueMicrotask): https://nodejs.org/docs/latest/api/globals.html -
Node.js API, file system, timers, stream, events, DNS and readline (promise variants): https://nodejs.org/docs/latest/api/fs.html, https://nodejs.org/docs/latest/api/timers.html, https://nodejs.org/docs/latest/api/stream.html, https://nodejs.org/docs/latest/api/events.html, https://nodejs.org/docs/latest/api/dns.html, https://nodejs.org/docs/latest/api/readline.html
-
Node.js API, ECMAScript modules and errors (top-level await,
ERR_REQUIRE_ASYNC_MODULE): https://nodejs.org/docs/latest/api/esm.html, https://nodejs.org/docs/latest/api/errors.html -
ECMAScript language specification (promises, async functions, async iteration): https://tc39.es/ecma262/
-
MDN Web Docs, Promise: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise