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

Table 1. APIs and behaviours covered on this page
API or behaviour Stability (Node.js 26 docs) Version notes

Error, error.cause, Error.captureStackTrace(), Error.stackTraceLimit

Stable (the Node.js errors page; cause documented since v16.9.0)

captureStackTrace and stackTraceLimit are V8 features that Node.js documents; the stack text format is not a stable contract.

error.code on Node.js errors

Stable identifier

Changes only between major versions; error.message may change in any version.

System errors (ENOENT, EADDRINUSE, …​) with errno, syscall, path, address, port

Stable

util.getSystemErrorMessage() was added in v23.1.0 and v22.12.0; util.getSystemErrorName() in v9.7.0.

Error.isError()

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 Error.isError is undefined.

AggregateError

JavaScript language feature

Available on every supported line; Promise.any() rejects with it.

process.on('uncaughtException')

Stable

The origin argument was added in v12.0.0 and v10.17.0.

process.on('uncaughtExceptionMonitor')

Stable

Added in v13.7.0 and v12.17.0.

process.on('unhandledRejection') and --unhandled-rejections=mode

Stable

Default mode changed to throw in v15.0.0 (a warning before).

process.on('rejectionHandled')

Stable

Added in v1.4.1.

node:assert and node:assert/strict

2 Stable

Messages may be a function or printf-style format since v26.0.0; assert.Assert added in v24.6.0 and v22.19.0; assert.partialDeepStrictEqual() Stable since v24.0.0 and v22.17.0.

node:domain

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.

Table 2. Error categories in Node.js
Category Typical class and identification Typical cause Usual reaction

Standard JavaScript errors

TypeError, RangeError, ReferenceError, SyntaxError, URIError (and the legacy EvalError); identify with the class and name

Bugs in the code, or bad data passed to a language feature such as JSON.parse()

Fix the bug; validate external data at the boundary

System errors

Plain Error objects with code (ENOENT, …​), errno, syscall, and sometimes path, address, port, dest

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

TypeError, RangeError or Error with a string code starting with ERR_ (plus ABORT_ERR)

A Node.js API was called wrongly, or a Node.js-level condition occurred (module not found, premature stream close)

Branch on code; argument errors (ERR_INVALID_ARG_TYPE) are bugs

DOMException

name such as 'AbortError'; code is a legacy number

Web-platform APIs: fetch(), AbortSignal, Web Streams

Check err.name

Assertion errors

AssertionError with code 'ERR_ASSERTION', actual, expected, operator

An invariant that should never be false was false

A bug; do not swallow

User-specified errors

Your own Error subclasses

Application rules (not found, validation, forbidden)

Map to a response or a user message

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.

Provoking each standard error and printing its name and message
// 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}`);
  }
}
Output on Node.js 26 (identical on 24 and 22; the V8 message wording can differ between versions)
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

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.

Table 3. The four error channels
API style How the error arrives What you write

Synchronous (fs.readFileSync(), JSON.parse())

Thrown with throw

try/catch

Promise-returning (fs/promises, fetch())

The promise rejects

await inside try/catch, or .catch()

Callback-taking (fs.readFile(path, cb))

First argument of the callback is an Error, or null

if (err) …​ as the first statement

Event-based (streams, sockets, servers, EventEmitter)

An 'error' event on the emitter

emitter.on('error', handler) before the failure can happen

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 channels, plus an unhandled 'error' event
// 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);
}
Output on Node.js 26
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.

A stream pipeline: errors are forwarded to the awaiting caller
// 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);
}
Output on Node.js 26 (identical on 24 and 22)
pipeline rejected: bad row
ERR_STREAM_PREMATURE_CLOSE

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.

Reading the anatomy of a file-system error
// 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
}
Output on Node.js 26 (identical on 24 and 22)
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

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

Table 4. Common system error codes and what usually causes them
Code Meaning Where it typically appears

ENOENT

No such file or directory

fs operations, spawning a missing executable

EACCES, EPERM

Permission denied; operation not permitted

fs, binding to a privileged port, child_process

EEXIST, ENOTEMPTY, EISDIR, ENOTDIR

Target exists; directory not empty; is a directory; not a directory

fs.mkdir(), fs.rmdir(), reading a directory as a file

EMFILE

Too many open files for the process

Opening many files in parallel; raise the limit with ulimit -n or limit concurrency

EADDRINUSE

Address already in use

listen() of net, http or https servers

ECONNREFUSED

Nothing is listening at the target address

net.connect(), HTTP clients, database drivers

ECONNRESET

The peer closed the connection abruptly

http and net, long-lived sockets

EPIPE

Write to a connection the other side has closed

net, http, child process pipes

ETIMEDOUT

The peer did not respond in time

http and net connects and sends

ENOTFOUND

DNS lookup failed (EAI_NODATA or EAI_NONAME); not a POSIX error

dns.lookup(), http, fetch()

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.

EADDRINUSE, ECONNREFUSED and ENOTFOUND
// 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);
}
Output on Node.js 26 (identical on 24 and 22)
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.

Network failures in fetch() are TypeErrors with the system error as cause
// 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);
}
Output on Node.js 26 (identical on 24 and 22)
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.

Raising several different ERR_* errors on purpose
// 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);
Output on Node.js 26 (identical on 24 and 22)
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
Table 5. ERR_* codes worth recognising on sight
Code Meaning Usual fix

ERR_INVALID_ARG_TYPE

An argument of the wrong type was passed to a Node.js API

Fix the call; this is a bug

ERR_INVALID_ARG_VALUE

The type is right but the value is invalid or unsupported

Fix the call or validate input first

ERR_OUT_OF_RANGE

A value is outside the accepted range

Check bounds

ERR_MISSING_ARGS

A required argument was not passed

Fix the call

ERR_MODULE_NOT_FOUND

The ES module loader could not resolve an import

Check the specifier, the file extension and package.json "exports"

ERR_PACKAGE_PATH_NOT_EXPORTED

"exports" does not export the requested subpath

Import a public subpath, or ask the package author

ERR_UNKNOWN_FILE_EXTENSION

The loader does not know the file extension

Use a supported extension or a loader hook

ERR_REQUIRE_ASYNC_MODULE

require() of an ES module that contains top-level await

Use import(); since v26.5.0 the error carries requireStack and topLevelAwaitLocations

ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING

Type stripping does not run for files under node_modules

Publish JavaScript, not TypeScript

ERR_STREAM_PREMATURE_CLOSE

A stream or pipeline ended without an explicit error but before completion

Treat as a failure of the pipeline

ERR_UNHANDLED_ERROR

An 'error' event with no listener, or with a non-Error value

Add an 'error' listener

ERR_MULTIPLE_CALLBACK

A callback was called more than once

Fix the control flow that calls it

ERR_HTTP_HEADERS_SENT

Headers were set after they were already sent

Return after sending the response

ERR_SERVER_ALREADY_LISTEN

listen() called on a server that is already listening

Call it once

ERR_ACCESS_DENIED

The permission model blocked access to a resource

Grant the matching --allow-* flag if intended

ERR_ASSERTION

An AssertionError from node:assert

A bug; see the assertion section below

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.

Branching on error.code, wrapping with a cause, and letting the rest 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);
}
Output on Node.js 26 (identical on 24 and 22)
{}
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
// 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));
Output on Node.js 26 (identical on 24 and 22)
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.

The stack header, constructorOpt and stackTraceLimit
// 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);
Output on Node.js 26 (identical on 24 and 22; the middle part shows the helper frame hidden)
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.

A TypeScript error class with a narrowing catch (run with node file.ts)
// 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;
}
Output on Node.js 26 (identical on 24 and on 22.18 or later)
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.

Wrapping with a cause and walking the chain
// 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));
}
Output on Node.js 26 (identical on 24 and 22)
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.

Promise.allSettled(), Promise.any() and constructing an AggregateError
// 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);
Output on Node.js 26 (identical on 24 and 22)
2 failed of 3
true AggregateError | All promises were rejected
[ 'mirror A down', 'mirror B down' ]
2 nightly job

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 (needs Node.js 24 or later)
// 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));
Output on Node.js 26 (the same on 24; on 22 the example stops with its own message)
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.

Classifying errors by code, name and an explicit marker
// 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');
  }
}
Output on Node.js 26 (identical on 24 and 22)
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.

Handle one case, add context to another, rethrow the rest
// 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); }
Output on Node.js 26 (identical on 24 and 22; the path is shortened)
{ 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 handler that maps error types to responses
// 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();
Output on Node.js 26 (identical on 24 and 22)
200 {"id":1,"title":"Dune"}
404 {"error":"Book 9 not found"}
404 {"error":"No such route"}
unexpected: TypeError
500 {"error":"Internal error"}

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.

The default: print the stack, exit with code 1 (internal frames omitted, path shortened)
// Default behaviour: an uncaught exception prints the stack and exits with code 1
console.log('starting');
setTimeout(() => console.log('never printed'), 100);
null.title;
Output on Node.js 26
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 that reports synchronously and exits non-zero
// 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);
Output on Node.js 26 (the exit code is 1)
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: swallowing the exception keeps a broken process alive and exits with 0
// 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);
Output on Node.js 26 (the exit code is 0)
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.

Monitoring without changing the outcome
// 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'));
Output on Node.js 26 (internal frames omitted; the exit code is 1)
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 escalated to the global handler reports its origin
// 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();
Output on Node.js 26 (the exit code is 1 because the handler sets process.exitCode)
uncaughtException unhandledRejection async failure
An 'error' event without a listener and a failed socket both reach the global handler
// 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
Output on Node.js 26 (identical on 24 and 22)
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.

A thrown string, with and without --trace-uncaught
// Throwing a non-Error: the stack of the *throw* is lost unless --trace-uncaught is set
throw 'plain string';
Output with --trace-uncaught on Node.js 26 (the line with the origin of the throw is added by the flag)
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, with and without an unhandledRejection hook
// 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);

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.

Table 6. Observed behaviour of --unhandled-rejections modes (Node.js 26)
Mode No unhandledRejection listener With a listener

throw (default)

Raised as an uncaught exception: stack printed, exit code 1, the timer never runs

The listener runs, the process continues and exits with 0

strict

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

warn

A warning is printed, the process continues, exit code 0

The listener runs and the warning is printed as well; exit code 0

warn-with-error-code

A warning is printed, the process continues, exit code 1

The listener runs; exit code 0

none

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.

Tracking the growing and shrinking set of unhandled rejections
// 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);
Output on Node.js 26 (identical on 24 and 22)
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.

Crash-only handlers for the Bookshelf server
// 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);
Output on Node.js 26 (the process exits with code 1)
{"level":"fatal","kind":"unhandledRejection","name":"TypeError","message":"cannot read properties of undefined"}

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.

Reacting to a deprecation warning with the 'warning' event
// 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');
Output on Node.js 26 (on 24 the output is identical; on 22 no warning is emitted for this call)
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.

Anatomy of an AssertionError with a strict diff
// 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);
}
Output on Node.js 26 (identical on 24 and 22)
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.

Asserting on errors with throws, rejects and ifError
// 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');
Output on Node.js 26 (identical on 24 and 22)
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, partial matching and strict versus legacy comparison
// 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); }
Output on Node.js 26
pages must match false
RangeError thrown as is
wanted 2, got 1
partial match ok
strict mode: ERR_ASSERTION

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 state invariants; validation reports operational errors
// 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);
}
Output on Node.js 26 (identical on 24 and 22)
15
true ERR_ASSERTION | percent out of range: 125

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

Table 7. Error-related changes by version
Version Change Effect on your code

v15.0.0

--unhandled-rejections defaults to throw; unhandled rejections crash the process

Always handle or await promises

v16.9.0

error.cause documented and supported in the Error constructor

Wrap errors with { cause }

v22.12.0 and v23.1.0

util.getSystemErrorMessage() added

Human text for a numeric errno

v22.12.0, v23.0.0 and v20.19.0

require() loads synchronous ES modules; ERR_REQUIRE_ESM deprecated

Fewer module-format errors; ERR_REQUIRE_ASYNC_MODULE remains for top-level await

v22.17.0 and v24.0.0

assert.partialDeepStrictEqual() becomes Stable

Safe to use for subset checks

v24.0.0

url.parse() (DEP0169) becomes an application deprecation and warns

Use the WHATWG URL

Node.js 24 line (V8 13.6)

Error.isError() available (observed present in 24.21.0 and 26.10.0, missing in 22.23.3)

Feature-detect it on Node.js 22

v24.6.0 and v22.19.0

assert.Assert class

Independent assertion instances

v24.8.0

CPU-profile error codes (ERR_CPU_PROFILE_*)

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

ERR_NO_TEMPORAL and the package-map error codes

New codes to recognise

v26.5.0

ERR_REQUIRE_ASYNC_MODULE gets requireStack and topLevelAwaitLocations

Better diagnostics for top-level await

v26.9.0

MAC errors (ERR_CRYPTO_MAC_*)

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 newer ERR_* codes listed in the recent-changes table.

  • The chapter on errors in Efficient Node.js describes ENOTFOUND as an entity that was not found; it is a DNS lookup failure (getaddrinfo), and the missing file or directory case is ENOENT.

  • 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

Table 8. Common error-handling mistakes and the fix
Mistake Fix

Matching on err.message

Match on err.code or err.name; messages change between versions

No 'error' listener on a socket, stream, server or child process

Add one at creation time, or use stream/promises pipeline()

A try/catch around code that starts an asynchronous operation

Await the operation inside the try, or handle the callback or event

Promise created and never awaited

Await it, return it, or attach a handler; the default throw mode makes this fatal

'uncaughtException' handler that only logs

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 cause

Pass { cause: err } so the chain survives

finally containing return or throw

Keep finally for cleanup only

Throwing strings or plain objects

Throw Error instances; they have a stack and work with Error.isError()

Using assertions for user input

Validate input and throw operational errors; assertions are for invariants

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.