Errors & Error Handling
|
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. It is the most detailed page of the section because error handling decides whether a Node.js service survives its first real incident: the runtime has several different error channels (exceptions, callbacks, rejected promises, 'error' events), a family of system errors that come from the operating system, a catalogue of several hundred stable ERR_* codes, and two process-wide safety nets whose correct use is widely misunderstood. The language-level mechanics of throw, try/catch and Error subclasses are covered in Exception Handling; this page owns what is specific to Node.js. The running scenario is the Bookshelf application that grows across this section, described in Introduction & Architecture.
The Node.js documentation lists five categories of error that an application meets: standard JavaScript errors, DOMException`s, system errors, assertion errors and user-specified errors. All errors raised by Node.js are instances of, or inherit from, the standard `Error class, so they are guaranteed to have at least the properties of that class. One rule from the same page drives most of the advice below: the error.message text may change in any release, while error.code only changes between major versions, so program against the code and never against the message.
The stability and version map
| API or behaviour | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
Stable (the Node.js errors page; |
|
|
Stable identifier |
Changes only between major versions; |
System errors ( |
Stable |
|
|
JavaScript language feature (V8) |
Present on Node.js 24.21.0 (V8 13.6) and 26.10.0; observed missing on Node.js 22.23.3 (V8 12.4), where |
|
JavaScript language feature |
Available on every supported line; |
|
Stable |
The |
|
Stable |
Added in v13.7.0 and v12.17.0. |
|
Stable |
Default mode changed to |
|
Stable |
Added in v1.4.1. |
|
2 Stable |
Messages may be a function or printf-style format since v26.0.0; |
|
0 Deprecated (documented as pending deprecation) |
Deprecated since v1.4.2 in the docs; do not use it for error handling. |
The categories of error
Before looking at any code, it helps to have a map. The right reaction to an error depends on which row of this table it falls in.
| Category | Typical class and identification | Typical cause | Usual reaction |
|---|---|---|---|
Standard JavaScript errors |
|
Bugs in the code, or bad data passed to a language feature such as |
Fix the bug; validate external data at the boundary |
System errors |
Plain |
The operating system refused or could not do something: missing file, port in use, connection refused |
Handle the expected ones (retry, default, report to the caller); let the rest propagate |
Node.js error codes |
|
A Node.js API was called wrongly, or a Node.js-level condition occurred (module not found, premature stream close) |
Branch on |
|
|
Web-platform APIs: |
Check |
Assertion errors |
|
An invariant that should never be false was false |
A bug; do not swallow |
User-specified errors |
Your own |
Application rules (not found, validation, forbidden) |
Map to a response or a user message |
Source: Node.js API: Errors
Standard JavaScript errors
The language raises six native error classes. V8 throws them for faults inside the program, and Node.js reuses TypeError and RangeError as the class of its own argument-validation errors, which then also carry an ERR_* code. The Node.js documentation states that ReferenceError instances almost always indicate a bug in the code or its dependencies, that SyntaxError is only produced by code evaluation (eval, Function, require and the node:vm module; JSON.parse() throws one as well), and that a SyntaxError is unrecoverable in the context that created it.
// Standard JavaScript errors and what raises them
const attempts = {
ReferenceError: () => missingVariable,
TypeError: () => null.title,
RangeError: () => new Array(-1),
SyntaxError: () => JSON.parse('{ "title": '),
URIError: () => decodeURIComponent('%'),
};
for (const [expected, run] of Object.entries(attempts)) {
try {
run();
} catch (err) {
console.log(`${expected.padEnd(14)} -> ${err.name}: ${err.message}`);
}
}
ReferenceError -> ReferenceError: missingVariable is not defined
TypeError -> TypeError: Cannot read properties of null (reading 'title')
RangeError -> RangeError: Invalid array length
SyntaxError -> SyntaxError: Unexpected end of JSON input
URIError -> URIError: URI malformed
Source: Node.js API: Errors
EvalError is still a class, but modern engines no longer throw it themselves, so you will essentially never see one outside old tutorials. A DOMException is a different thing: it is the Web IDL class thrown by fetch(), AbortController and Web Streams. Its name identifies it (for example 'AbortError'), and unlike every other Node.js error its code is a legacy number (20 for ABORT_ERR). The Node.js documentation points out that Node.js-specific APIs that accept an AbortSignal, such as events.once(), throw a native Error named AbortError with the code 'ABORT_ERR' instead; checking err?.name === 'AbortError' recognises both. Cancellation is covered in Async Context & Cancellation.
Two facts from the "Exceptions vs. errors" part of the documentation are worth remembering. JavaScript can throw any value, but everything thrown by Node.js or the runtime is an Error instance. And some exceptions are unrecoverable at the JavaScript layer and always crash the process, for example a failed assert() in the C++ layer or an abort() call.
How an error reaches you: four channels
The same failure is reported in a different way depending on the style of the API. This is the first thing to learn, because each channel needs a different handler, and a handler for one channel does not see errors from another.
| API style | How the error arrives | What you write |
|---|---|---|
Synchronous ( |
Thrown with |
|
Promise-returning ( |
The promise rejects |
|
Callback-taking ( |
First argument of the callback is an |
|
Event-based (streams, sockets, servers, |
An |
|
A handful of typically asynchronous methods still throw synchronously; the documentation says there is no comprehensive list and each method’s page has to be consulted. The examples below show all four channels and what happens when an 'error' event has no listener.
// The four ways Node.js reports an error
import { readFile as readFileCb } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import { EventEmitter } from 'node:events';
const missing = new URL('./no-such-file.json', import.meta.url);
// 1. Synchronous API: throw
try {
readFileSync(missing);
} catch (err) {
console.log('throw ', err.code);
}
// 2. Callback API: error first
readFileCb(missing, (err) => {
console.log('callback ', err?.code);
});
// 3. Promise API: rejection
try {
await readFile(missing);
} catch (err) {
console.log('promise ', err.code);
}
// 4. EventEmitter: 'error' event
const emitter = new EventEmitter();
emitter.on('error', (err) => console.log('event ', err.message));
emitter.emit('error', new Error('connection lost'));
// Without a listener the same emit throws
try {
new EventEmitter().emit('error', new Error('nobody is listening'));
} catch (err) {
console.log('no listener', err.message);
}
try {
new EventEmitter().emit('error', 'a plain string');
} catch (err) {
console.log('non-Error ', err.code, '|', err.context);
}
throw ENOENT
callback ENOENT
promise ENOENT
event connection lost
no listener nobody is listening
non-Error ERR_UNHANDLED_ERROR | a plain string
The last two lines show the rule for 'error' events: if an emitter emits 'error' with no listener, the error is thrown (and, if nothing catches it, becomes an uncaught exception that crashes the process). If the emitted value is not an Error, Node.js wraps it in an error with the code ERR_UNHANDLED_ERROR and keeps the original value in the context property. Such errors cannot be caught with try/catch around the code that started the operation, because they are thrown after the calling code has already returned. This is why every socket, server, stream and child process you create needs an 'error' listener, and why the promise-based stream/promises helpers are preferred: they turn the event into a rejection. See Events & EventEmitter and Callbacks, Promises & async/await for the details of each style.
// Streams and pipelines: errors travel through the 'error' event or the promise
import { pipeline } from 'node:stream/promises';
import { Readable, Transform } from 'node:stream';
const failing = new Transform({
transform(chunk, encoding, callback) {
callback(new Error('bad row'));
},
});
try {
await pipeline(Readable.from(['a,b\n', 'c,d\n']), failing, async function (source) {
for await (const chunk of source) console.log(String(chunk));
});
} catch (err) {
console.log('pipeline rejected:', err.message);
}
// A stream that ends without an error but too early
const early = new Readable({ read() {} });
setImmediate(() => early.destroy());
try {
await pipeline(early, async function (source) { for await (const _ of source); });
} catch (err) {
console.log(err.code);
}
pipeline rejected: bad row
ERR_STREAM_PREMATURE_CLOSE
Source: Node.js API: Stream, Node.js API: Errors
pipeline() destroys every stream in the chain when one fails and rejects with the first error. When a stream ends without an explicit error but before it should have, the rejection has the code ERR_STREAM_PREMATURE_CLOSE, which the second part of the example provokes.
System errors and error.code
System errors are generated when the runtime meets an operating-system constraint: opening a file that does not exist, binding to a port that is taken, writing to a closed socket. The documentation describes a SystemError class with code, errno, syscall, path, dest, address, port and info properties, and says that errno is a negative number that corresponds to a libuv error code. In practice, what you receive from fs, net, dns and the other modules is an ordinary Error instance carrying those properties (the example prints err.constructor.name, which is observed to be Error on all three lines), so instanceof Error is true and the properties are what you test.
// System errors carry code, errno, syscall and path
import { readFile, mkdir, rmdir } from 'node:fs/promises';
import { getSystemErrorName, getSystemErrorMessage } from 'node:util';
try {
await readFile('/definitely/not/here.txt', 'utf8');
} catch (err) {
console.log(err instanceof Error, err.constructor.name);
console.log({
code: err.code,
errno: err.errno,
syscall: err.syscall,
path: err.path,
name: getSystemErrorName(err.errno),
text: getSystemErrorMessage(err.errno),
});
console.log(err.message);
}
try {
await readFile(new URL('.', import.meta.url));
} catch (err) {
console.log(err.code, err.syscall); // EISDIR
}
try {
await mkdir(new URL('.', import.meta.url));
} catch (err) {
console.log(err.code, err.syscall); // EEXIST
}
try {
await rmdir(new URL('.', import.meta.url));
} catch (err) {
console.log(err.code, err.syscall); // ENOTEMPTY
}
true Error
{
code: 'ENOENT',
errno: -2,
syscall: 'open',
path: '/definitely/not/here.txt',
name: 'ENOENT',
text: 'no such file or directory'
}
ENOENT: no such file or directory, open '/definitely/not/here.txt'
EISDIR read
EEXIST mkdir
ENOTEMPTY rmdir
Source: Node.js API: Errors (system errors), Node.js API: util.getSystemErrorName() and util.getSystemErrorMessage()
The message of a system error always has the shape CODE: description, syscall 'path', which is convenient to read in a log but never something to parse. util.getSystemErrorName() turns a numeric errno into its name, util.getSystemErrorMessage() (Node.js 23.1 and 22.12 or later) returns the platform’s text for it, and util.getSystemErrorMap() returns every code the platform knows; the mapping is platform dependent.
The codes you will meet
| Code | Meaning | Where it typically appears |
|---|---|---|
|
No such file or directory |
|
|
Permission denied; operation not permitted |
|
|
Target exists; directory not empty; is a directory; not a directory |
|
|
Too many open files for the process |
Opening many files in parallel; raise the limit with |
|
Address already in use |
|
|
Nothing is listening at the target address |
|
|
The peer closed the connection abruptly |
|
|
Write to a connection the other side has closed |
|
|
The peer did not respond in time |
|
|
DNS lookup failed ( |
|
The next example provokes the three network errors you see most often in a Bookshelf deployment: a second server on a taken port, a connection to a port nobody listens on, and a lookup of a host name that cannot resolve.
// Network system errors: EADDRINUSE, ECONNREFUSED, ENOTFOUND
import net from 'node:net';
import dns from 'node:dns/promises';
const first = net.createServer().listen(0, '127.0.0.1');
await new Promise((resolve) => first.once('listening', resolve));
const { port } = first.address();
// EADDRINUSE: a second server on the same address
const second = net.createServer();
second.on('error', (err) => {
console.log(err.code, err.syscall, err.address, err.port === port);
first.close();
});
second.listen(port, '127.0.0.1');
await new Promise((resolve) => first.once('close', resolve));
// ECONNREFUSED: nothing listens on the port any more
const socket = net.connect({ port, host: '127.0.0.1' });
await new Promise((resolve) => socket.once('error', (err) => {
console.log(err.code, err.syscall, err.address, err.port === port);
resolve();
}));
// ENOTFOUND: DNS failure (the .invalid TLD never resolves)
try {
await dns.lookup('bookshelf.invalid');
} catch (err) {
console.log(err.code, err.syscall, err.hostname);
}
EADDRINUSE listen 127.0.0.1 true
ECONNREFUSED connect 127.0.0.1 true
ENOTFOUND getaddrinfo bookshelf.invalid
ENOTFOUND deserves a remark because it is easy to misread. It is not "entity not found" (the missing-file case is ENOENT); it means the DNS lookup failed, and its syscall is getaddrinfo. In the example the reserved .invalid top-level domain never resolves, so the result does not depend on the network, although a machine that is offline can report a different DNS code such as EAI_AGAIN for other names.
fetch() follows the web platform instead: a network failure rejects with a TypeError whose message is fetch failed and whose cause is the underlying error, so the system code is one level down. An HTTP error status such as 404 or 500 is not an error at all; the promise resolves and you check response.ok.
// fetch wraps network failures in a TypeError; the system error is the cause
import net from 'node:net';
// Find a port that is free right now
const probe = net.createServer().listen(0, '127.0.0.1');
await new Promise((resolve) => probe.once('listening', resolve));
const { port } = probe.address();
await new Promise((resolve) => probe.close(resolve));
try {
await fetch(`http://127.0.0.1:${port}/books`, { signal: AbortSignal.timeout(2000) });
} catch (err) {
console.log(err.name, '-', err.message);
console.log(err.cause?.code, err.cause?.syscall);
}
// An aborted request rejects with a DOMException named AbortError
try {
await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.abort() });
} catch (err) {
console.log(err.name, err.constructor.name, err.code);
}
TypeError - fetch failed
ECONNREFUSED connect
AbortError DOMException 20
The port is obtained by briefly opening and closing a server, because fetch() refuses a few well-known "bad ports" (port 9 for example) with a different error. Fetching, timeouts and retries are covered in depth on the Fetch & HTTP Clients page.
The ERR_* catalogue
Errors that Node.js itself raises (as opposed to the operating system) carry a stable string code that begins with ERR_. The catalogue in the Node.js 26 documentation has roughly 430 entries (counted on 2026-10-03), grouped by area: arguments (ERR_INVALID_ARG_TYPE), modules (ERR_MODULE_NOT_FOUND), streams (ERR_STREAM_PREMATURE_CLOSE), HTTP, crypto, the test runner, the permission model and so on. The single exception to the naming rule is ABORT_ERR, which is kept without the prefix for compatibility with the web platform’s AbortError. Each Node.js error has the class that fits its meaning (TypeError for a wrong type, RangeError for an out-of-range value, plain Error for most others), and its name and code are shown in the stack trace.
// The Node.js error codes: match on code, never on message
import { readFileSync } from 'node:fs';
import { writeFile, rm } from 'node:fs/promises';
import { Buffer } from 'node:buffer';
const odd = new URL('./data.xyz', import.meta.url);
await writeFile(odd, 'x');
const cases = {
'wrong type': () => readFileSync({}),
'bad value': () => readFileSync('books\0.json'),
'out of range': () => Buffer.alloc(8).readUInt32LE(6),
'unknown encoding': () => Buffer.from('ff', 'rot13'),
'invalid URL': () => new URL('/relative'),
'missing module': () => import('./does-not-exist.mjs'),
'unknown extension': () => import(odd),
};
for (const [label, run] of Object.entries(cases)) {
try {
await run();
} catch (err) {
console.log(`${label.padEnd(18)} ${String(err.code).padEnd(26)} ${err.name}`);
}
}
await rm(odd);
wrong type ERR_INVALID_ARG_TYPE TypeError
bad value ERR_INVALID_ARG_VALUE TypeError
out of range ERR_OUT_OF_RANGE RangeError
unknown encoding ERR_UNKNOWN_ENCODING TypeError
invalid URL ERR_INVALID_URL TypeError
missing module ERR_MODULE_NOT_FOUND Error
unknown extension ERR_UNKNOWN_FILE_EXTENSION TypeError
Source: Node.js API: Node.js error codes
| Code | Meaning | Usual fix |
|---|---|---|
|
An argument of the wrong type was passed to a Node.js API |
Fix the call; this is a bug |
|
The type is right but the value is invalid or unsupported |
Fix the call or validate input first |
|
A value is outside the accepted range |
Check bounds |
|
A required argument was not passed |
Fix the call |
|
The ES module loader could not resolve an |
Check the specifier, the file extension and |
|
|
Import a public subpath, or ask the package author |
|
The loader does not know the file extension |
Use a supported extension or a loader hook |
|
|
Use |
|
Type stripping does not run for files under |
Publish JavaScript, not TypeScript |
|
A stream or pipeline ended without an explicit error but before completion |
Treat as a failure of the pipeline |
|
An |
Add an |
|
A callback was called more than once |
Fix the control flow that calls it |
|
Headers were set after they were already sent |
Return after sending the response |
|
|
Call it once |
|
The permission model blocked access to a resource |
Grant the matching |
|
An |
A bug; see the assertion section below |
Source: Node.js API: Node.js error codes
The catalogue keeps growing with the runtime. Recent additions in the documentation include ERR_CPU_PROFILE_ALREADY_STARTED and its siblings (v24.8.0), ERR_CRYPTO_MAC_FINALIZED and the other MAC errors (v26.9.0), ERR_NO_TEMPORAL (v26.2.0, raised when a feature needs Temporal but the build or environment disabled it), the package-map errors ERR_PACKAGE_MAP_INVALID, ERR_PACKAGE_MAP_KEY_NOT_FOUND and ERR_PACKAGE_MAP_EXTERNAL_FILE (v26.4.0), and ERR_REQUIRE_ESM_RACE_CONDITION (v26.1.0, Experimental). ERR_REQUIRE_ESM is marked Deprecated: since v23.0.0 and v22.12.0 (and v20.19.0) require() loads synchronous ES modules by default, so the old error only remains for modules that cannot be loaded that way. See ESM & CJS Interop for the modules side.
The practical rule is to branch on code, treat the message as text for humans, and let anything you did not expect propagate.
// Match on error.code, not on the message
import { readFile } from 'node:fs/promises';
async function readConfig(path) {
try {
return JSON.parse(await readFile(path, 'utf8'));
} catch (err) {
switch (err.code) {
case 'ENOENT':
return {}; // no config file is a normal situation
case 'EACCES':
case 'EISDIR':
throw new Error(`Cannot read ${path}`, { cause: err });
default:
throw err; // includes SyntaxError from a broken file, which has no code
}
}
}
console.log(await readConfig(new URL('./nope.json', import.meta.url)));
try {
await readConfig('/');
} catch (err) {
console.log(err.message, '<-', err.cause.code);
}
{}
Cannot read / <- EISDIR
Note how the default branch rethrows the original error: a SyntaxError from a broken file has no code, so it propagates to whoever can deal with it.
Custom errors
Application rules deserve their own error types. A good custom error is a small Error subclass with a stable name, a machine-readable code, optional structured fields, and a safe-to-expose flag so that the HTTP layer knows whether the message may be shown to a client. Pass cause through to the parent constructor instead of inventing your own field.
// A custom error hierarchy for the Bookshelf API
export class AppError extends Error {
constructor(message, { code = 'APP_ERROR', status = 500, expose = false, cause } = {}) {
super(message, { cause });
this.name = new.target.name;
this.code = code;
this.status = status;
this.expose = expose;
Error.captureStackTrace?.(this, new.target);
}
toJSON() {
return { name: this.name, code: this.code, message: this.expose ? this.message : 'Internal error' };
}
}
export class NotFoundError extends AppError {
constructor(resource, id, options) {
super(`${resource} ${id} was not found`, { code: 'NOT_FOUND', status: 404, expose: true, ...options });
this.resource = resource;
this.id = id;
}
}
export class ValidationError extends AppError {
constructor(problems) {
super('The request is invalid', { code: 'VALIDATION', status: 400, expose: true });
this.problems = problems;
}
}
const err = new NotFoundError('book', 42);
console.log(err instanceof NotFoundError, err instanceof AppError, err instanceof Error);
console.log(err.name, err.code, err.status);
console.log(JSON.stringify(err));
console.log(err.stack.split('\n')[0]);
console.log(Object.prototype.toString.call(err));
true true true
NotFoundError NOT_FOUND 404
{"name":"NotFoundError","code":"NOT_FOUND","message":"book 42 was not found"}
NotFoundError: book 42 was not found
[object Error]
Some design points visible in that code. new.target.name gives each subclass its own name without repeating it. toJSON() decides what leaves the process, so an internal error serialises as a generic message. And instanceof works across the hierarchy, which is what lets one catch clause handle a whole family.
Stack traces, captureStackTrace and stackTraceLimit
A stack trace is captured when the error is created (new Error()), and V8 formats the text lazily: the first line, Name: message, is built the first time error.stack is read. The consequence, which the documentation hints at ("changing this property after the Error object is created may not change the first line of the stack trace"), is that renaming an error after something already read its stack leaves the old header in err.stack while String(err) shows the new name. Error.captureStackTrace(target[, constructorOpt]) creates a fresh .stack on any object and rebuilds the header from the current name and message. Its optional second argument hides every frame above and including that function, which is how a validation helper can make the trace start at its caller. Error.stackTraceLimit (default 10) caps the number of frames; it affects only traces captured after the change, and setting it to a non-number or a negative number captures no frames.
// captureStackTrace, constructorOpt and stackTraceLimit
// The stack header is built the first time .stack is read
const early = new Error('read early');
early.stack; // header "Error: read early" is fixed here
early.name = 'Renamed';
console.log(early.stack.split('\n')[0]);
console.log(String(early));
const late = new Error('read late');
late.name = 'Renamed'; // .stack has not been read yet
console.log(late.stack.split('\n')[0]);
// captureStackTrace rebuilds the header from the current name and message
const rebuilt = new Error('rebuilt');
rebuilt.stack;
rebuilt.name = 'Renamed';
Error.captureStackTrace(rebuilt);
console.log(rebuilt.stack.split('\n')[0]);
// constructorOpt hides helper frames
function assertPositive(n) {
if (n <= 0) {
const err = new RangeError(`expected a positive number, got ${n}`);
Error.captureStackTrace(err, assertPositive);
throw err;
}
}
function addBook(pages) {
assertPositive(pages);
}
try {
addBook(-1);
} catch (err) {
console.log(err.stack.split('\n').slice(0, 3).map((l) => l.replace(/\(.*[/\\]/, '(')).join('\n'));
}
// Error.stackTraceLimit
function deep(n) { return n === 0 ? new Error('deep') : deep(n - 1); }
console.log(Error.stackTraceLimit, deep(30).stack.split('\n').length - 1);
Error.stackTraceLimit = 3;
console.log(deep(30).stack.split('\n').length - 1);
Error: read early
Renamed: read early
Renamed: read late
Renamed: rebuilt
RangeError: expected a positive number, got -1
at addBook (c2.mjs:29:3)
at file:///app/c2.mjs:32:3
10 10
3
Two operational notes. Frames exist only for JavaScript functions, so a synchronous call through a C++ addon does not appear as a frame of its own. And, as the documentation says, a trace extends only to the beginning of synchronous code execution or to the frame limit, whichever is smaller. For readable traces of transpiled code, use --enable-source-maps (which makes a best-effort mapping to the original sources); note that overriding Error.prepareStackTrace can interfere with it.
Custom errors in TypeScript
Node.js runs .ts files directly through type stripping (Stable since v24.12.0 and v25.2.0, enabled by default since v23.6 and v22.18), which is enough for error classes as long as the syntax is erasable: no enums, no parameter properties, no namespaces. In a catch clause the variable is unknown under strict settings, so narrow with instanceof before reading code.
// Custom errors in TypeScript run directly with type stripping (no enums, no parameter properties)
type Problem = { field: string; message: string };
class ValidationError extends Error {
readonly problems: Problem[];
constructor(problems: Problem[], options?: ErrorOptions) {
super(`Invalid book: ${problems.map((p) => p.field).join(', ')}`, options);
this.name = 'ValidationError';
this.problems = problems;
}
}
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
return err instanceof Error && 'code' in err;
}
function validate(book: { title?: string; pages?: number }): void {
const problems: Problem[] = [];
if (!book.title) problems.push({ field: 'title', message: 'required' });
if (!Number.isInteger(book.pages)) problems.push({ field: 'pages', message: 'must be an integer' });
if (problems.length) throw new ValidationError(problems);
}
try {
validate({ pages: 1.5 });
} catch (err: unknown) { // catch variables are unknown, so narrow before use
if (err instanceof ValidationError) console.log(err.message, err.problems.length);
else if (isErrnoException(err)) console.log(err.code);
else throw err;
}
Invalid book: title, pages 2
The NodeJS.ErrnoException type used by the guard comes from @types/node; type stripping erases it, and an editor or tsc --noEmit is what checks it. More on this in TypeScript in Node.js.
Error causes and AggregateError
new Error(message, { cause }) records the underlying error in a non-enumerable cause property (documented since v16.9.0). It lets a layer translate a low-level failure into its own vocabulary without losing the original: the repository layer reports "could not load book 7" and the original ENOENT is still one property away. util.inspect() (and therefore console.log() and the default crash output) prints the whole chain recursively under a [cause] heading.
// Error cause chains
import { readFile } from 'node:fs/promises';
import { inspect } from 'node:util';
Error.stackTraceLimit = 0; // keep this demo output short
class RepositoryError extends Error {}
async function loadBook(id) {
try {
return JSON.parse(await readFile(`/no/such/books/${id}.json`, 'utf8'));
} catch (cause) {
throw new RepositoryError(`Could not load book ${id}`, { cause });
}
}
try {
await loadBook(7);
} catch (err) {
console.log(err.message);
console.log(err.cause.code);
console.log(Object.hasOwn(err, 'cause'), Object.keys(err));
// Walk the chain
for (let e = err; e; e = e.cause) {
console.log(' ->', e.constructor.name, e.code ?? '');
}
console.log(inspect(err));
}
Could not load book 7
ENOENT
true []
-> RepositoryError
-> Error ENOENT
[RepositoryError: Could not load book 7] {
[cause]: [Error: ENOENT: no such file or directory, open '/no/such/books/7.json'] {
errno: -2,
code: 'ENOENT',
syscall: 'open',
path: '/no/such/books/7.json'
}
}
cause is not set when the option is omitted, is not enumerable (so Object.keys(err) and JSON.stringify(err) do not show it), and can hold any value, not only errors. A log formatter that wants the whole story has to walk the chain itself.
AggregateError carries several errors in an errors array. Promise.any() rejects with one when every input rejects, and you can build one when a batch job should report all of its failures instead of only the first. Promise.allSettled() is the alternative when you want every outcome, successes included.
// AggregateError: several failures reported as one
const results = await Promise.allSettled([
Promise.resolve('book 1'),
Promise.reject(new Error('book 2 failed')),
Promise.reject(new TypeError('book 3 failed')),
]);
const failures = results.filter((r) => r.status === 'rejected').map((r) => r.reason);
console.log(failures.length, 'failed of', results.length);
try {
await Promise.any([
Promise.reject(new Error('mirror A down')),
Promise.reject(new Error('mirror B down')),
]);
} catch (err) {
console.log(err instanceof AggregateError, err.name, '|', err.message);
console.log(err.errors.map((e) => e.message));
}
// Throw your own, and build one from the failures above
const combined = new AggregateError(failures, 'Import finished with errors', {
cause: new Error('nightly job'),
});
console.log(combined.errors.length, combined.cause.message);
2 failed of 3
true AggregateError | All promises were rejected
[ 'mirror A down', 'mirror B down' ]
2 nightly job
Source: Node.js API: Errors, Node.js API: Globals
Promise.all() rejects with the first failure only and ignores the rest (they are still handled, so no unhandled rejection is reported). Choose between all, allSettled, any and race by the question you are asking, not by habit; the combinators are compared in Callbacks, Promises & async/await.
Recognising an error: Error.isError
err instanceof Error is false for an error that was created in another realm, for example inside node:vm, an iframe-like context or a Worker-adjacent boundary where the constructor is a different object. Error.isError(value) is the realm-independent check: it returns true for genuine built-in error objects (including DOMException and errors from other realms) and false for look-alike plain objects, objects that merely inherit from Error.prototype, and non-errors. The Node.js documentation uses it to explain that error.stack is a getter and setter for a hidden internal property that exists only on built-in error objects.
// Error.isError versus instanceof (Node.js 24 and later)
if (typeof Error.isError !== 'function') throw new Error('Error.isError needs Node.js 24 or later');
import vm from 'node:vm';
const foreign = vm.runInNewContext('new Error("from another realm")');
console.log(foreign instanceof Error); // false: different Error constructor
console.log(Error.isError(foreign)); // true
console.log(Error.isError(new DOMException('x', 'AbortError')));
console.log(Error.isError({ name: 'Error', message: 'fake', stack: '' }));
console.log(Error.isError(Object.create(Error.prototype)));
console.log(Object.prototype.toString.call(foreign));
false
true
true
false
false
[object Error]
On Node.js 22 Error.isError does not exist (V8 12.4), so code that has to run there should feature-detect it and fall back to instanceof Error, as in Error.isError?.(value) ?? value instanceof Error. Because thrown values can be anything, a catch clause that reads err.code on an unknown value should check first that the value is an object.
Operational errors versus programmer errors
The most useful classification for deciding what to do with an error splits them in two. Operational errors are failures of the environment that a correct program must expect: a missing file, a refused connection, a timeout, invalid user input. Programmer errors are bugs: reading a property of undefined, passing the wrong type to an API, a failed assertion. The reaction differs completely. Operational errors are handled where the program knows what to do (retry, fall back, return a 4xx, tell the user). A programmer error cannot be handled meaningfully, because the program is already in a state its author did not anticipate; the right response is to log it, fail the current unit of work if the damage is contained, and in the last resort crash and restart.
// Operational errors are handled; programmer errors are bugs
import { readFile } from 'node:fs/promises';
class ValidationError extends Error {
name = 'ValidationError';
operational = true; // our own marker for expected, recoverable failures
}
const OPERATIONAL_CODES = new Set([
'ENOENT', 'EACCES', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'EADDRINUSE', 'EPIPE',
]);
function isOperational(err) {
if (err?.operational === true) return true;
if (err?.name === 'AbortError') return true;
return OPERATIONAL_CODES.has(err?.code);
}
for (const run of [
() => readFile('/no/such/file'), // the world is not as expected
() => { throw new ValidationError('title is required'); },
() => { const book = null; return book.title; }, // a bug
]) {
try {
await run();
} catch (err) {
console.log(err.name.padEnd(16), isOperational(err) ? 'operational -> handle' : 'programmer -> crash');
}
}
Error operational -> handle
ValidationError operational -> handle
TypeError programmer -> crash
The classifier is deliberately a whitelist: only codes you decided are expected count as operational, and everything else is presumed to be a bug. Note also the places where classification depends on context. A SyntaxError from JSON.parse() is a programmer error when the text is a file you ship, but bad input when it arrives from a client; the boundary code that parses external data should translate it into a validation error (as in the HTTP example below) so that the rest of the program only meets typed operational errors.
Rethrowing and forwarding
Handle only the errors you can do something about, and make sure everything else keeps travelling. In try/catch that means a narrow condition and a throw err at the end; when you add context, create a new error with the original as cause so no information is lost; and in callback code you forward the error to your own callback with return callback(err) instead of throwing from an asynchronous context where nobody can catch it. In a promise chain a .catch() that does not rethrow converts a failure into a success value, so it must be intentional.
// Rethrowing: catch what you can handle, let everything else propagate
import { readFile } from 'node:fs/promises';
class ConfigError extends Error {
name = 'ConfigError';
}
async function loadSettings(path) {
let text;
try {
text = await readFile(path, 'utf8');
} catch (err) {
if (err.code === 'ENOENT') return { port: 3000 }; // expected: use defaults
throw err; // unexpected: do not swallow
}
try {
return JSON.parse(text);
} catch (err) {
// Add context, keep the original as cause, and keep the stack of the original
throw new ConfigError(`Settings file ${path} is not valid JSON`, { cause: err });
}
}
console.log(await loadSettings('/no/such/settings.json'));
try {
await loadSettings(new URL(import.meta.url).pathname); // this .mjs file is not JSON
} catch (err) {
console.log(err.name, '|', err.message.replace(/\/.*\//, '…/'), '|', err.cause.name);
}
// finally runs on every path; do not return or throw from it
function withCleanup() {
try {
throw new Error('first');
} finally {
console.log('cleanup runs');
}
}
try { withCleanup(); } catch (err) { console.log('still got:', err.message); }
{ port: 3000 }
ConfigError | Settings file …/d2.mjs is not valid JSON | SyntaxError
cleanup runs
still got: first
Every error path in a function should end in one of three outcomes: handled (a value or a response is produced), translated (a new error with cause is thrown) or propagated unchanged. An empty catch block is a fourth outcome that is always a mistake, because it makes a failure invisible. A finally block runs on every path, so it is the right place for cleanup, but a return or throw inside finally replaces the outcome of the whole statement.
Mapping errors at the HTTP boundary
The edge of the application is where internal errors become public answers. A single handler maps the typed errors to status codes and messages, and treats everything else as an internal error: log the detail on the server, return a generic message to the client, and never include stack traces, file paths or SQL in the response.
// A node:http server that maps errors to responses and never leaks internals
import http from 'node:http';
import { once } from 'node:events';
class HttpError extends Error {
constructor(status, message, options) {
super(message, options);
this.status = status;
this.name = 'HttpError';
}
}
const books = new Map([[1, { id: 1, title: 'Dune' }]]);
async function route(req) {
const url = new URL(req.url, 'http://localhost');
const match = /^\/books\/(\d+)$/.exec(url.pathname);
if (!match) throw new HttpError(404, 'No such route');
const book = books.get(Number(match[1]));
if (!book) throw new HttpError(404, `Book ${match[1]} not found`);
if (url.searchParams.has('boom')) null.title; // a programmer error
return book;
}
const server = http.createServer(async (req, res) => {
try {
const body = await route(req);
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(body));
} catch (err) {
if (err instanceof HttpError) {
res.writeHead(err.status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err.message }));
return;
}
console.error('unexpected:', err.name); // log the details server-side
res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'Internal error' }));
}
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const base = `http://127.0.0.1:${server.address().port}`;
for (const path of ['/books/1', '/books/9', '/nope', '/books/1?boom=1']) {
const res = await fetch(base + path);
console.log(res.status, await res.text());
}
server.close();
200 {"id":1,"title":"Dune"}
404 {"error":"Book 9 not found"}
404 {"error":"No such route"}
unexpected: TypeError
500 {"error":"Internal error"}
Source: Node.js API: HTTP, Node.js API: Errors
In this example a null.title bug becomes a 500 with a fixed body, while the log line (here, on standard error) carries the name for the operator. The REST API built on this pattern is in HTTP Server Fundamentals and Building a REST API; framework-level error middleware is in Express & Fastify.
The last line of defence: uncaughtException and unhandledRejection
If an exception is thrown and nothing catches it, or a promise rejects and nothing handles it, the error reaches the process. What happens next is a policy decision with a well-documented answer: the process is in an undefined state, so it should report the failure and exit, and a supervisor should start a new one.
uncaughtException
By default an uncaught exception prints the stack trace to standard error and exits with code 1, overriding any process.exitCode that was set earlier.
// Default behaviour: an uncaught exception prints the stack and exits with code 1
console.log('starting');
setTimeout(() => console.log('never printed'), 100);
null.title;
starting
file:///app/e1.mjs:4
null.title;
^
TypeError: Cannot read properties of null (reading 'title')
at file:///app/e1.mjs:4:6
Node.js v26.10.0
Registering an 'uncaughtException' listener replaces that default. The listener receives the error and an origin string, 'uncaughtException' or 'unhandledRejection'. The documentation is blunt about what the event is for: it is a crude last-resort mechanism, not an equivalent of "On Error Resume Next"; resuming normal operation after it is not safe, and the correct use is to perform synchronous cleanup of resources and then shut down. To restart reliably, use an external monitor in another process (a container orchestrator, systemd, a process manager), which also covers the failures that never reach the event, such as running out of memory or being killed. Exceptions thrown from inside the listener itself are not caught; the process exits with a non-zero code to prevent infinite recursion.
// A handler turns the crash into your own policy, but the process state is still unsafe
import { writeSync } from 'node:fs';
process.on('uncaughtException', (err, origin) => {
writeSync(process.stderr.fd, `fatal (${origin}): ${err.message}\n`);
// Only synchronous cleanup is safe here. Then leave with a failure code.
process.exit(1);
});
setTimeout(() => { throw new Error('boom in a timer'); }, 10);
fatal (uncaughtException): boom in a timer
fs.writeSync() is used for the report because asynchronous logging may never flush before the process exits. The next example is the classic mistake: a handler that only logs. The timers keep running, the application carries on in an unknown state, and the exit code is 0 so no supervisor ever notices a problem.
// Mistake: a handler that only logs keeps a corrupted process alive, and exits with code 0
import { writeSync } from 'node:fs';
process.on('uncaughtException', (err, origin) => {
writeSync(process.stderr.fd, `logged (${origin}): ${err.message}\n`);
});
setTimeout(() => console.log('still running after the exception'), 50);
setTimeout(() => { throw new Error('swallowed'); }, 10);
logged (uncaughtException): swallowed
still running after the exception
If you only want to observe, without changing behaviour, use the 'uncaughtExceptionMonitor' event. It fires before 'uncaughtException' listeners (or a callback installed with process.setUncaughtExceptionCaptureCallback()), and installing it does not stop the process from crashing. It is the right hook for crash reporters.
// uncaughtExceptionMonitor observes the failure without changing what happens next
import { writeSync } from 'node:fs';
process.on('uncaughtExceptionMonitor', (err, origin) => {
writeSync(process.stderr.fd, `monitor saw ${origin}: ${err.message}\n`);
});
Promise.reject(new Error('rejected and nobody cared'));
monitor saw unhandledRejection: rejected and nobody cared
file:///app/e4.mjs:8
Promise.reject(new Error('rejected and nobody cared'));
^
Error: rejected and nobody cared
at file:///app/e4.mjs:8:16
Node.js v26.10.0
Several things reach 'uncaughtException' that do not look like exceptions at first. An 'error' event with no listener does, and so does a rejected promise when the --unhandled-rejections mode raises it as an exception. Thrown values that are not `Error`s arrive as they are, without a stack.
// A rejection inside a handler: origin tells you which path delivered it
process.on('uncaughtException', (err, origin) => {
console.log('uncaughtException', origin, err.message);
process.exitCode = 1;
});
async function load() { throw new Error('async failure'); }
load();
uncaughtException unhandledRejection async failure
// An 'error' event with no listener becomes an uncaught exception
import { EventEmitter } from 'node:events';
import net from 'node:net';
process.on('uncaughtException', (err, origin) => {
console.log(origin, '|', err.code ?? err.name, err.syscall ?? '');
});
setImmediate(() => new EventEmitter().emit('error', new Error('no listener here')));
setImmediate(() => net.connect({ port: 1, host: '127.0.0.1' })); // refused, and no 'error' handler
uncaughtException | Error
uncaughtException | ECONNREFUSED connect
The first example shows that origin is 'unhandledRejection' for a rejection that was escalated. The second shows that a failed net.connect() without an 'error' listener lands in the global handler with the original system code, which is why the fix belongs at the call site and not in the handler. Observed on 26, 24 and 22: a top-level throw in the entry point of an ES module is also reported with the origin 'unhandledRejection', because module evaluation is promise based; the documentation describes this as a rejection during the static loading phase of the entry point.
Related command-line flags change how the crash looks. --trace-uncaught additionally prints the stack where the value was thrown, which is useful when a non-Error value was thrown and has no stack of its own. --abort-on-uncaught-exception aborts instead of exiting, producing a core file for post-mortem analysis with a debugger. --report-uncaught-exception and --report-on-fatalerror generate a diagnostic report on an uncaught exception or a fatal runtime error; see Diagnostic Reports, Logging & Observability.
// Throwing a non-Error: the stack of the *throw* is lost unless --trace-uncaught is set
throw 'plain string';
node:internal/modules/run_main:111
triggerUncaughtException(
^
plain string
Thrown at:
at asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:111:5)
Node.js v26.10.0
unhandledRejection
A promise is unhandled when it rejects and no handler is attached within a turn of the event loop. Since v15.0.0 the default --unhandled-rejections mode is throw: Node.js first emits 'unhandledRejection'; if there is no listener for it, the rejection is raised as an uncaught exception, which means the process crashes with exit code 1 like any other uncaught exception. Before v15 it was only a warning.
// The same rejection under each --unhandled-rejections mode
const hooked = process.argv.includes('--hook');
if (hooked) {
process.on('unhandledRejection', (reason, promise) => {
console.log('hook:', reason.message, promise instanceof Promise);
});
}
Promise.reject(new Error('lost'));
setTimeout(() => console.log('timer ran'), 50);
Source: Node.js API: Process (event unhandledRejection), Node.js API: Command-line options (--unhandled-rejections)
The script was run once for each mode, with and without --hook, on Node.js 26, 24 and 22 (the results were the same on all three). The table shows the observed behaviour.
| Mode | No unhandledRejection listener |
With a listener |
|---|---|---|
|
Raised as an uncaught exception: stack printed, exit code 1, the timer never runs |
The listener runs, the process continues and exits with 0 |
|
Raised as an uncaught exception: exit code 1 |
Still raised as an uncaught exception and exit code 1; the listener is not called unless the exception itself is handled |
|
A warning is printed, the process continues, exit code 0 |
The listener runs and the warning is printed as well; exit code 0 |
|
A warning is printed, the process continues, exit code 1 |
The listener runs; exit code 0 |
|
Silent; the process continues, exit code 0 |
The listener runs; exit code 0 |
Whatever the mode, a rejection that happens during the static loading phase of the entry point of an ES module is always raised as an uncaught exception. Prefer the default throw mode in services: a rejected promise nobody awaited is a bug, and failing fast makes it visible.
Rejections can also be handled late. When a handler is attached after the 'unhandledRejection' event, Node.js emits 'rejectionHandled'. The set of unhandled rejections therefore grows and shrinks over time, and the documentation suggests tracking it in a Map and logging it periodically or at exit.
// A late .catch() fires 'rejectionHandled'; track the growing and shrinking set
const pending = new Map();
process.on('unhandledRejection', (reason, promise) => pending.set(promise, reason));
process.on('rejectionHandled', (promise) => pending.delete(promise));
const slow = Promise.reject(new Error('handled too late'));
await new Promise((resolve) => setTimeout(resolve, 20));
console.log('unhandled now:', pending.size);
slow.catch(() => {});
await new Promise((resolve) => setTimeout(resolve, 20));
console.log('unhandled after late catch:', pending.size);
unhandled now: 1
unhandled after late catch: 0
A crash-only policy for services
Putting the rules together gives a small, boring policy that works for HTTP services and workers alike. On 'uncaughtException' or 'unhandledRejection': set a non-zero exit code, write one structured log line with synchronous I/O, stop accepting new work (server.close()), give in-flight requests a bounded grace period, and exit. A supervisor restarts the process. Exceptions that were caught and handled at a boundary, such as the HTTP mapping above, do not trigger it.
// A crash-only policy for the Bookshelf server: log synchronously, stop accepting work, exit non-zero
import http from 'node:http';
import { writeSync } from 'node:fs';
const server = http.createServer((req, res) => res.end('ok'));
server.listen(0, '127.0.0.1');
let shuttingDown = false;
function fatal(kind, err) {
if (shuttingDown) return;
shuttingDown = true;
process.exitCode = 1;
// 1. Report with synchronous I/O: async loggers may never flush
writeSync(process.stderr.fd, JSON.stringify({ level: 'fatal', kind, name: err?.name, message: err?.message }) + '\n');
// 2. Stop taking new connections and let in-flight requests finish
server.close(() => process.exit());
server.closeIdleConnections();
// 3. Never hang: force the exit after a grace period (unref so it cannot keep the process alive)
setTimeout(() => process.exit(), 5000).unref();
}
process.on('uncaughtException', (err) => fatal('uncaughtException', err));
process.on('unhandledRejection', (reason) => fatal('unhandledRejection', reason));
// Demonstration: a bug fires 100 ms after start-up
setTimeout(() => Promise.reject(new TypeError('cannot read properties of undefined')), 100);
{"level":"fatal","kind":"unhandledRejection","name":"TypeError","message":"cannot read properties of undefined"}
Source: Node.js API: Process (events uncaughtException and unhandledRejection), Node.js API: HTTP (server.close())
The unref() on the force-exit timer matters: without it the timer itself would keep a process alive that could otherwise end. The same shutdown sequence on SIGTERM is the subject of Process, Signals & Exit Codes, container restart policies are in Production Deployment, and the NestJS equivalent is in Lifecycle Events & Shutdown.
The node:domain module was an older attempt at this problem; its page carries Stability 0 (Deprecated) and describes the module as pending deprecation, the errors documentation mentions it only as an escape hatch for unhandled 'error' events, and it should not be used in new code. AsyncLocalStorage is the supported way to carry a request identifier into a fatal log line, see Async Context & Cancellation.
Warnings and deprecations
Not every problem is an error. Node.js emits process warnings (deprecations, experimental features, suspicious usage) through the 'warning' event and prints them to standard error. --trace-warnings adds the stack of the code that produced the warning, --trace-deprecation does so for deprecations only, --no-deprecation silences them, and --throw-deprecation turns a deprecation into a thrown error, which is a good setting for a CI job that should fail on use of deprecated APIs.
// Deprecations arrive as process warnings; flags change how loud they are
import { parse } from 'node:url';
process.on('warning', (w) => console.log('warning event:', w.name, w.code));
parse('http://example.test/books?id=1');
console.log('after parse');
after parse
(node:4242) [DEP0169] DeprecationWarning: `url.parse()` behavior is not standardized and prone to errors that have security implications. Use the WHATWG URL API instead. CVEs are not issued for `url.parse()` vulnerabilities.
(Use `node --trace-deprecation ...` to show where the warning was created)
warning event: DeprecationWarning DEP0169
url.parse() (DEP0169) is an application deprecation: since Node.js 24 it warns for code outside node_modules, while on 22 the deprecation is documentation only, which is why the same script is silent there. Running with --throw-deprecation turns the same call into an uncaught DeprecationWarning error with exit code 1 on 24 and 26.
Assertion errors
The node:assert module (2 Stable) verifies invariants. A failing assertion throws an AssertionError, a subclass of Error whose code is always 'ERR_ASSERTION' and whose actual, expected, operator and generatedMessage properties describe the failure. Import node:assert/strict (the strict assertion mode): its equal, deepEqual and notEqual behave like the strict variants, and failure messages for objects show a diff. The legacy mode (plain node:assert) compares with loose equality, which hides bugs.
// AssertionError anatomy and strict diffs
import assert from 'node:assert/strict';
try {
assert.deepStrictEqual({ title: 'Dune', pages: 412 }, { title: 'Dune', pages: 421 });
} catch (err) {
console.log(err.name, err.code, err.operator, err.generatedMessage);
console.log(err.actual, err.expected);
console.log(err.message);
}
AssertionError ERR_ASSERTION deepStrictEqual true
{ title: 'Dune', pages: 412 } { title: 'Dune', pages: 421 }
Expected values to be strictly deep-equal:
+ actual - expected
{
+ pages: 412,
- pages: 421,
title: 'Dune'
}
assert.throws() and assert.rejects() check that the right error is thrown, optionally validating it against a class, a regular expression, a validation object whose properties are compared, or a function that returns true. assert.ifError(value) is the legacy adapter for error-first callbacks: it passes for null and undefined and throws for anything else.
// assert.throws and assert.rejects validate the error, not only that something was thrown
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
assert.throws(() => JSON.parse('{'), SyntaxError);
assert.throws(() => Buffer.alloc(-1), { code: 'ERR_OUT_OF_RANGE', name: 'RangeError' });
assert.throws(() => new URL('/x'), /Invalid URL/);
await assert.rejects(readFile('/no/such/file'), { code: 'ENOENT', syscall: 'open' });
await assert.rejects(Promise.reject(new TypeError('x')), (err) => {
assert.equal(err.name, 'TypeError');
return true;
});
// assert.throws fails if nothing is thrown
try {
assert.throws(() => {}, Error);
} catch (err) {
console.log(err.code, '|', err.message.split('\n')[0]);
}
// assert.ifError passes null/undefined and rethrows anything else
assert.ifError(null);
try {
assert.ifError(new Error('callback failed'));
} catch (err) {
console.log(err.code, '|', err.message.split('\n')[0]);
}
console.log('all assertions passed');
ERR_ASSERTION | Missing expected exception (Error).
ERR_ASSERTION | ifError got unwanted exception: callback failed
all assertions passed
The optional last argument is the failure message. It can be a string, an Error instance (which is then thrown instead of an AssertionError) or, since v26.0.0, a function (actual, expected) ⇒ string that is evaluated only on failure; extra arguments after a string message are treated as printf-style substitutions, and combining extra arguments with an Error or a function is rejected with ERR_AMBIGUOUS_ARGUMENT. assert.partialDeepStrictEqual() (Stable since v24.0.0 and v22.17.0) compares only the properties named in the expected value, which is convenient for large API responses. Since v25.0.0 the deep-equality family no longer considers two different promises equal and treats invalid dates as equal. assert.Assert (v24.6.0, v22.19.0) creates independent assertion instances with their own options such as a full diff.
// Custom messages: string, Error instance, or a function evaluated only on failure
import assert from 'node:assert/strict';
import legacy from 'node:assert';
const first = (err) => err.message.split('\n')[0];
try { assert.equal(1, 2, 'pages must match'); } catch (err) { console.log(first(err), err.generatedMessage); }
try { assert.equal(1, 2, new RangeError('thrown as is')); } catch (err) { console.log(err.name, err.message); }
try { assert.equal(1, 2, (actual, expected) => `wanted ${expected}, got ${actual}`); } catch (err) { console.log(first(err)); }
// Subset comparison
assert.partialDeepStrictEqual({ id: 1, title: 'Dune', tags: ['sf', 'classic'] }, { title: 'Dune', tags: ['sf'] });
console.log('partial match ok');
// Legacy mode compares with ==, strict mode with Object.is
legacy.equal('1', 1);
try { assert.equal('1', 1); } catch (err) { console.log('strict mode:', err.code); }
pages must match false
RangeError thrown as is
wanted 2, got 1
partial match ok
strict mode: ERR_ASSERTION
Source: Node.js API: Assert
The message form that takes a function is new in Node.js 26: on 24 and 22 the same call does not fail, but the function is not called either, and the message is the function’s source text, which is observed behaviour and not an error. Code that has to run on all three lines should pass strings.
An assertion error is a programmer error by definition, so it is a way to state conditions that must hold inside the program, not a way to validate input from users. A client sending an out-of-range percentage must get a 400, not an AssertionError that ends up as a 500.
// Assertions are not input validation, and assertion errors are programmer errors
import assert from 'node:assert/strict';
function applyDiscount(price, percent) {
assert(Number.isFinite(price) && price >= 0, 'price must be a non-negative number');
assert(percent >= 0 && percent <= 100, `percent out of range: ${percent}`);
return price * (1 - percent / 100);
}
console.log(applyDiscount(20, 25));
try {
applyDiscount(20, 125);
} catch (err) {
console.log(err instanceof assert.AssertionError, err.code, '|', err.message);
}
15
true ERR_ASSERTION | percent out of range: 125
Source: Node.js API: Assert
The test runner (node:test) builds on these errors: a failing test is reported with the AssertionError or its cause, and ERR_TEST_FAILURE wraps a failed test with the original error in cause. See The Node.js Test Runner.
What changed recently
| Version | Change | Effect on your code |
|---|---|---|
v15.0.0 |
|
Always handle or await promises |
v16.9.0 |
|
Wrap errors with |
v22.12.0 and v23.1.0 |
|
Human text for a numeric |
v22.12.0, v23.0.0 and v20.19.0 |
|
Fewer module-format errors; |
v22.17.0 and v24.0.0 |
|
Safe to use for subset checks |
v24.0.0 |
|
Use the WHATWG |
Node.js 24 line (V8 13.6) |
|
Feature-detect it on Node.js 22 |
v24.6.0 and v22.19.0 |
|
Independent assertion instances |
v24.8.0 |
CPU-profile error codes ( |
Profiling API errors have codes |
v25.0.0 |
Deep equality: different promises are not equal, invalid dates are equal |
Re-check tests that compared such values |
v26.0.0 |
Assertion messages may be printf-style or functions |
Do not rely on it if you support 24 or 22 |
v26.2.0 and v26.4.0 |
|
New codes to recognise |
v26.5.0 |
|
Better diagnostics for top-level |
v26.9.0 |
MAC errors ( |
New codes for the MAC API |
Where the books are outdated
Two O’Reilly books were consulted as references for coverage: Efficient Node.js by Samer Buna (2025) and Node.js Projects by Jonathan Wexler (2025). Both are good introductions, but they were written against Node.js 22 and earlier baselines ("Node 20 or higher" in the first, Node 22.16 or later in the second), so several things on this page cannot appear in them.
-
Anything newer than Node.js 22 is missing:
Error.isError()(Node.js 24 and later), the printf-style and function forms of assertion messages (v26.0.0),assert.Assert(v24.6.0),url.parse()warning at runtime (v24.0.0), and the newerERR_*codes listed in the recent-changes table. -
The chapter on errors in Efficient Node.js describes
ENOTFOUNDas an entity that was not found; it is a DNS lookup failure (getaddrinfo), and the missing file or directory case isENOENT. -
In Node.js Projects an error object returned by an LLM call is treated as if it were an answer; failures must be converted into typed errors at the boundary and propagated, never passed along as values.
The authoritative sources are the Node.js documentation pages cited on this page; the publisher pages are listed under References.
Pitfalls checklist
| Mistake | Fix |
|---|---|
Matching on |
Match on |
No |
Add one at creation time, or use |
A |
Await the operation inside the |
|
Await it, return it, or attach a handler; the default |
|
Log synchronously, clean up synchronously, exit non-zero, let a supervisor restart |
Resuming normal operation after an uncaught exception |
Do not; the process state is undefined |
Returning internal messages, stacks or paths to clients |
Map errors at the boundary; log details server-side |
Re-throwing a new error without |
Pass |
|
Keep |
Throwing strings or plain objects |
Throw |
Using assertions for user input |
Validate input and throw operational errors; assertions are for invariants |
Source: Node.js API: Errors, Node.js API: Process
For debugging a failing process with the inspector and for production monitoring of errors, continue with Debugging and Diagnostic Reports, Logging & Observability; the security consequences of leaking error details are in Security Best Practices.