Node.js delivers errors through four separate channels: throw, callbacks, Promise rejections, and EventEmitter 'error' events — missing any one channel means silent failures
A Promise rejection without .catch() crashes the process in Node.js 15+ (and still does in Node.js 22 LTS)
Custom error classes with isOperational flag let your global handler distinguish bugs from expected failures — without this flag, you either crash on every validation error or keep running after a genuine bug
Express async route handlers silently swallow errors unless wrapped with an asyncHandler utility — this is the most common Express production bug in 2026
Promise.allSettled lets partial results succeed when one operation fails — use it for dashboards and independent operations, not all-or-nothing flows
The most dangerous bug: calling an async function without await inside try/catch — the catch block never fires, no error surfaces, and data is silently lost
Node.js 22 LTS ships with a stable built-in diagnostics channel API that makes structured error observability significantly easier without third-party APM agents
Plain-English First
Imagine you're a chef running a restaurant kitchen. When an order comes in for a dish you're out of ingredients for, you don't just silently throw the ticket in the bin — you tell the waiter, who tells the customer, who can then order something else. Node.js error handling is exactly that chain of communication. Without it, your app quietly fails while your users stare at a spinning loader, wondering what happened. The tricky part is that in Node.js, there are four completely separate ways an error can happen — and you need to intercept every one of them. Miss even one, and failures disappear without a trace. Good error handling is how your app says 'something went wrong, and here's what' instead of dying in silence.
Node.js powers millions of production servers, APIs, and real-time applications — and the difference between an app that recovers gracefully from failure and one that crashes at 2 AM taking your database connection pool with it comes down almost entirely to error handling. It is not a nice-to-have. It is the foundation of reliable software, and it is also the area where experienced engineers make the most expensive mistakes.
The core problem Node.js introduces is that errors can arrive from multiple timelines simultaneously. A file read, a database query, an outbound HTTP call, and a timer can all fail at different moments, and if you are not deliberately intercepting each failure path, Node.js will eventually throw an uncaught exception and terminate your process. Worse, with Promises, errors can fail completely silently unless you have wired up rejection handlers — no crash, no log, no alert, just missing data.
In 2026, Node.js 22 LTS is the active long-term support release. The error handling model has not fundamentally changed since Node.js 15 introduced the throwing unhandledRejection default, but Node.js 22 brings a stable diagnostics channel API that makes structured error observability meaningfully easier, and the --experimental-require-module flag is now stable, which affects how error handling patterns compose across ESM and CommonJS boundaries in mixed codebases.
By the end of this article you will understand the four error delivery mechanisms in Node.js, how to build a layered error handling strategy using custom error classes, when to use try/catch versus .catch(), how to handle process-level uncaught exceptions safely, and how to structure middleware-based error handling in an Express API. Every pattern here is grounded in real production behaviour, not toy examples.
The Four Ways Node.js Delivers Errors — and Why Missing One Means Silent Failure
In a browser, errors mostly arrive from one or two places: synchronous throws and maybe a fetch() rejection. Node.js is different in a way that surprises engineers who come from frontend backgrounds. Because Node.js is designed for I/O-heavy work — reading files, querying databases, making HTTP requests, managing socket connections — errors arrive through four completely separate delivery channels. Miss any one of them and you have silent failures that only surface under production load, or worse, never surface at all.
The first channel is synchronous throws. These work exactly as you would expect — a try/catch block handles them reliably. The second is the error-first callback pattern, the original Node.js convention established in the early fs and http core modules, where the first argument to every callback is always an Error object or null. The third is Promise rejections, which arrived with modern async APIs and require either .catch() chaining or try/catch inside async functions. The fourth is EventEmitter error events, used by streams, HTTP servers, database connections, and most core network modules — if you do not attach an 'error' event listener and the emitter fires one, Node.js throws an uncaught exception by default, even in Node.js 22.
Understanding which channel a library uses is the first question you should ask before integrating anything new into your codebase. The Node.js core fs module uses error-first callbacks in its synchronous-style async API and Promises in the fs/promises variant. The native fetch() API (stable in Node.js 21+, available in Node.js 22) uses Promise rejections. An HTTP or TCP server uses EventEmitter. Most modern ORMs use Promises. Get the channel wrong and you have error handling code that looks completely correct, passes code review, and catches nothing.
io/thecodeforge/errors/errorChannels.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
// Node.js 22 LTS — all four error channels demonstrated with realistic examples// Run with: node errorChannels.js
import { readFile } from 'fs/promises'; // ESM — stable in Node.js 22import { createReadStream } from'fs';
import { EventEmitter } from'events';
// ─────────────────────────────────────────────────────────────────// CHANNEL 1: Synchronous throw// Common sources: JSON.parse, URL constructor, Buffer operations,// synchronous validators, any third-party parser library// ─────────────────────────────────────────────────────────────────functionparseWebhookPayload(rawBody) {
// JSON.parse throws synchronously on malformed input// Under high load, this is a common event loop blocker if payloads are largereturnJSON.parse(rawBody);
}
try {
const payload = parseWebhookPayload('{ malformed json }');
} catch (syncError) {
// syncError is a SyntaxError — we can inspect type and message
console.log('[Channel 1 - Sync] Caught:', syncError.constructor.name, '-', syncError.message);
}
// ─────────────────────────────────────────────────────────────────// CHANNEL 2: Error-first callback (the original Node.js pattern)// Still used by: some core modules via legacy APIs, many npm packages// pre-2018, child_process, some database drivers// Note: fs/promises is the modern alternative — prefer it for new code// ─────────────────────────────────────────────────────────────────import { readFile as readFileCallback } from'fs';
readFileCallback('/tmp/file-that-does-not-exist.txt', 'utf8', (callbackError, fileContents) => {
if (callbackError) {
// ALWAYS check the first argument// ALWAYS return immediately after handling — execution continues otherwise// Missing this return causes 'Cannot set headers after they are sent' in Express
console.log('[Channel 2 - Callback] Caught:', callbackError.code, '-', callbackError.message);
return; // ← This return is not optional. Treat it as mandatory.
}
// Only reaches here if callbackError is null
console.log('File contents:', fileContents);
});
// ─────────────────────────────────────────────────────────────────// CHANNEL 3: Promise rejection// Common sources: fetch(), fs/promises, most modern ORMs and HTTP clients,// any async function that throws, any explicitly rejected Promise// In Node.js 22: native fetch() is fully stable — uses this channel// ─────────────────────────────────────────────────────────────────asyncfunctionfetchUserFromDatabase(userId) {
// Simulates a database call that rejects — e.g. connection timeoutif (!userId || typeof userId !== 'number') {
returnPromise.reject(newTypeError(`fetchUserFromDatabase expects a number, got: ${typeof userId}`));
}
returnPromise.reject(newError(`User ${userId} not found in users table`));
}
asyncfunctionloadUserProfile(userId) {
try {
// Without this try/catch, the rejection becomes UnhandledPromiseRejection// In Node.js 22, that exits the process with code 1const user = awaitfetchUserFromDatabase(userId);
console.log('User loaded:', user);
} catch (promiseError) {
console.log('[Channel 3 - Promise] Caught:', promiseError.constructor.name, '-', promiseError.message);
}
}
awaitloadUserProfile(42);
// ─────────────────────────────────────────────────────────────────// CHANNEL 4: EventEmitter 'error' event// Common sources: streams (ReadStream, WriteStream, Transform),// net.Socket, http.Server, WebSocket connections, database connection pools// Rule: attach .on('error') BEFORE calling any methods on the emitter// ─────────────────────────────────────────────────────────────────const fileStream = createReadStream('/tmp/nonexistent-large-file.csv');
// If you remove this listener, the error event below crashes the process// Node.js 22 behaviour is unchanged: no listener = uncaught exception
fileStream.on('error', (streamError) => {
console.log('[Channel 4 - EventEmitter] Caught:', streamError.code, '-', streamError.message);
});
// The stream will emit 'error' when it tries to open the nonexistent file// This happens asynchronously — the error arrives after the next I/O cycle// ─────────────────────────────────────────────────────────────────// Node.js 22: diagnostics_channel for structured error observability// This is new and worth knowing — lets you subscribe to error events// across third-party libraries without modifying their code// ─────────────────────────────────────────────────────────────────import diagnostics_channel from'diagnostics_channel';
const errorChannel = diagnostics_channel.channel('app.error');
// Any code in any library can publish to this channel// Your error monitoring subscribes once — no monkey-patching requiredif (errorChannel.hasSubscribers) {
errorChannel.subscribe((errorEvent) => {
// Forward to your observability platform: Sentry, Datadog, OpenTelemetry
console.log('[DiagnosticsChannel] Error event received:', errorEvent.code);
});
}
// Publishing to the channel from your error handler:functionpublishError(error) {
if (diagnostics_channel.channel('app.error').hasSubscribers) {
diagnostics_channel.channel('app.error').publish({
code: error.errorCode || 'UNKNOWN',
message: error.message,
stack: error.stack,
});
}
}
Output
[Channel 1 - Sync] Caught: SyntaxError - Unexpected token m in JSON at position 2
[Channel 3 - Promise] Caught: Error - User 42 not found in users table
[Channel 4 - EventEmitter] Caught: ENOENT - no such file or directory, open '/tmp/nonexistent-large-file.csv'
[Channel 2 - Callback] Caught: ENOENT - no such file or directory, open '/tmp/file-that-does-not-exist.txt'
Four Error Channels, Four Catching Strategies — Plus One New Observability Tool
Synchronous throw: caught by try/catch — the simplest and most predictable. JSON.parse, URL constructor, and synchronous validators all use this channel.
Error-first callback: check the first argument — if truthy, it is an Error. Always return immediately after handling, or execution falls through to the success path with undefined data.
Promise rejection: caught by .catch() or await + try/catch. In Node.js 22, unhandled rejections exit the process with code 1 by default — there is no grace period.
EventEmitter 'error': attach .on('error', handler) before calling any methods. No handler means uncaught exception means process crash — unchanged in Node.js 22.
diagnostics_channel (Node.js 22 stable): subscribe to error events across third-party libraries without monkey-patching. Useful for APM integration and structured logging pipelines.
First question when integrating any library: which channel does it use? The answer determines what your error handling code needs to look like.
Production Insight
In error-first callbacks, forgetting to return after handling the error lets execution continue into the success path with undefined or null data.
In Express this manifests as 'Cannot set headers after they are sent to the client' — a confusing error that points to the wrong line.
In Node.js 22, the fs/promises module is the preferred way to do file I/O in new code — it uses Promises instead of callbacks, eliminating the need to remember the return-after-error pattern for file operations specifically.
Rule: in legacy callback code, always write if (err) { handle(err); return; } — treat the return as syntactically mandatory, not stylistically optional.
Key Takeaway
Node.js has four error channels — throw, callback, Promise, EventEmitter — each requiring a different catching mechanism.
Missing any one channel means silent failure that surfaces under production load, not during testing.
Node.js 22 adds stable diagnostics_channel support for structured cross-library error observability without modifying third-party code.
In Node.js 22, unhandled Promise rejections exit the process with code 1 — there is no longer a 'warning only' mode unless you explicitly set --unhandled-rejections=warn.
Which Error Channel Does This Library Use?
IfFunction accepts a callback as its last argument with signature (err, result)
→
UseError-first callback — check the first argument, return immediately after handling, never fall through to the success path
IfFunction returns a Promise or is declared async — includes fetch(), fs/promises, most modern ORMs
→
UsePromise rejection — use .catch() or await + try/catch. In Node.js 22, unhandled rejections exit the process. Never call without one of these.
UseEventEmitter 'error' event — attach .on('error', handler) before calling any methods on the object. Attach it before the object can possibly emit.
IfFunction executes synchronously and produces a result immediately — parsers, validators, constructors
→
UseSynchronous throw — wrap in try/catch. Check the docs: some libraries return null/undefined on failure rather than throwing.
IfNeed to observe errors across multiple libraries without modifying each one
→
UseUse diagnostics_channel (stable in Node.js 22) — subscribe once, receive structured error events from any library that publishes to the channel
Custom Error Classes — Stop Throwing Plain Strings and Start Throwing Structured Data
When you throw new Error('something failed') everywhere, your error handlers have almost nothing to work with. Is this a validation error the user caused? A database timeout you should retry? A third-party API going down? A configuration problem that will never resolve? You cannot tell — and neither can your monitoring tools, your on-call engineer at 3 AM, or the automated system that decides whether to restart the process.
The solution is custom error classes that extend the built-in Error. This gives every error a type you can check with instanceof, a machine-readable errorCode property you can switch on programmatically, an HTTP statusCode if you are building an API, an isOperational flag that tells your crash handler whether to keep running or exit, and any additional context — the offending field name, the failing query, the downstream service that timed out — that makes debugging faster. This is how Express, Mongoose, Prisma, and virtually every mature Node.js library handles errors internally.
The key insight is that errors are first-class data structures, not just messages. They carry information about what went wrong, who caused it, whether it is safe to retry, and how to respond to the client. A generic Error discards all of that context the moment it is created. A custom error class preserves it all the way from the database layer up through the service layer and into the HTTP response layer, so your global error handler can send a 422 for validation failures, a 503 for service timeouts, a 429 for rate limit breaches, and a 500 for genuine bugs — without needing a fragile if/else chain that parses error.message strings.
In 2026, with TypeScript being the standard for most serious Node.js codebases, custom error classes also benefit from full type safety — you can define the shape of each error class as an interface, and TypeScript will enforce that your catch blocks handle every error type your service can throw.
io/thecodeforge/errors/customErrors.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
// TypeScript — the standard for production Node.js in 2026// Compile with: tsc or ts-node customErrors.ts// ─────────────────────────────────────────────────────────────────// Base application error — all custom errors extend this// ─────────────────────────────────────────────────────────────────classAppErrorextendsError {
publicreadonly statusCode: number;
publicreadonly errorCode: string;
publicreadonly isOperational: boolean;
constructor(message: string, statusCode: number, errorCode: string) {
super(message);
this.name = this.constructor.name; // 'ValidationError', 'DatabaseError', etc.
this.statusCode = statusCode; // HTTP status to return to the client
this.errorCode = errorCode; // Machine-readable string for programmatic checks
this.isOperational = true; // This is a KNOWN error, not a bug// Preserves the correct stack trace pointing to the throw site, not this constructor// Essential for useful stack traces in production logsError.captureStackTrace(this, this.constructor);
}
}
// ─────────────────────────────────────────────────────────────────// Specific error types — each carries its own domain context// ─────────────────────────────────────────────────────────────────classValidationErrorextendsAppError {
publicreadonly fieldName: string;
publicreadonly receivedValue?: unknown;
constructor(message: string, fieldName: string, receivedValue?: unknown) {
super(message, 422, 'VALIDATION_ERROR');
this.fieldName = fieldName;
this.receivedValue = receivedValue; // Log this server-side — never send to client
}
}
classDatabaseErrorextendsAppError {
publicreadonly isRetryable: boolean;
publicreadonly queryContext?: Record<string, unknown>;
constructor(message: string, queryContext?: Record<string, unknown>, isRetryable = true) {
super(message, 503, 'DATABASE_UNAVAILABLE');
this.isRetryable = isRetryable;
this.queryContext = queryContext; // The failing operation — log for debugging, never expose to client
}
}
classNotFoundErrorextendsAppError {
publicreadonly resourceType: string;
publicreadonly resourceId: string | number;
constructor(resourceType: string, resourceId: string | number) {
super(`${resourceType} withID ${resourceId} was not found`, 404, 'NOT_FOUND');
this.resourceType = resourceType;
this.resourceId = resourceId;
}
}
classRateLimitErrorextendsAppError {
publicreadonly retryAfterSeconds: number;
constructor(retryAfterSeconds: number) {
super(`Rate limit exceeded. Retry after ${retryAfterSeconds} seconds.`, 429, 'RATE_LIMIT_EXCEEDED');
this.retryAfterSeconds = retryAfterSeconds;
}
}
classExternalServiceErrorextendsAppError {
publicreadonly serviceName: string;
publicreadonly upstreamStatus?: number;
constructor(serviceName: string, message: string, upstreamStatus?: number) {
super(message, 502, 'EXTERNAL_SERVICE_ERROR');
this.serviceName = serviceName;
this.upstreamStatus = upstreamStatus; // The HTTP status the upstream returned
}
}
// ─────────────────────────────────────────────────────────────────// Simulated service layer — throws typed errors with full context// ─────────────────────────────────────────────────────────────────asyncfunctioncreateUserAccount(userData: {
email?: string;
password?: string;
username?: string;
}): Promise<{ id: number; email: string }> {
if (!userData.email || !userData.email.includes('@')) {
thrownewValidationError(
'A valid email address is required',
'email',
userData.email // Logged server-side for debugging
);
}
if (!userData.password || userData.password.length < 12) {
thrownewValidationError(
'Password must be at least 12 characters',
'password'// Do not log the received value for passwords — ever
);
}
// Simulate a database connectivity failureconst isDatabaseReachable = false;
if (!isDatabaseReachable) {
thrownewDatabaseError(
'Primary database is unreachable — connection pool exhausted',
{ operation: 'INSERT', table: 'users', retryStrategy: 'exponential-backoff' },
true // This is retryable
);
}
return { id: 1001, email: userData.email };
}
// ─────────────────────────────────────────────────────────────────// Type-safe error handler using instanceof routing// ─────────────────────────────────────────────────────────────────asyncfunctionhandleCreateUser(requestBody: Record<string, unknown>): Promise<void> {
try {
const user = awaitcreateUserAccount(requestBody asany);
console.log('User created successfully:', user.id);
} catch (error) {
if (error instanceofValidationError) {
// 422 — user-caused, safe to surface field and message to client
console.log(`[422] Validation failed — field: '${error.fieldName}', message: ${error.message}`);
} elseif (error instanceofDatabaseError) {
// 503 — infrastructure failure, do NOT expose queryContext to client
console.log(`[503] Database error: ${error.message}`);
console.log(` Retryable: ${error.isRetryable}`);
console.log(` Query context: ${JSON.stringify(error.queryContext)}`);
} elseif (error instanceofExternalServiceError) {
// 502 — upstream dependency failed
console.log(`[502] External service '${error.serviceName}' failed: ${error.message}`);
console.log(` Upstream status: ${error.upstreamStatus}`);
} elseif (error instanceofAppError) {
// Catch-all for any AppError subclass not explicitly handled above
console.log(`[${error.statusCode}] Applicationerror (${error.errorCode}): ${error.message}`);
} else {
// This is a bug — an Error that nobody anticipated
console.error('[500] Unexpected error — this is a bug that needs investigation:');
console.error(error);
}
}
}
// Test with invalid emailawaithandleCreateUser({ email: 'not-an-email', password: 'securepassword123!!' });
// Test with weak passwordawaithandleCreateUser({ email: 'user@example.com', password: 'short' });
// Test with database downawaithandleCreateUser({ email: 'user@example.com', password: 'securepassword123!!' });
Output
[422] Validation failed — field: 'email', message: A valid email address is required
[422] Validation failed — field: 'password', message: Password must be at least 12 characters
[503] Database error: Primary database is unreachable — connection pool exhausted
The isOperational Flag — The Decision That Determines Process Stability
The isOperational: true flag on AppError is a pattern that has been in the Node.js community for over a decade, but it remains the most important single decision in your error handling architecture. It tells your process-level crash handler exactly what to do: an operational error (validation failure, not-found, rate limit, external service timeout) means log it, respond to the client appropriately, and keep the process running — this is expected behaviour. A non-operational error (null dereference, type error, logic bug, corrupted internal state) means log everything, alert the on-call team, and restart the process because the internal state cannot be trusted. Without this distinction, you either crash on every 404 (far too aggressive) or keep running after a genuine memory corruption bug (dangerously permissive). In Node.js 22, this flag is more important than ever because unhandled rejections now exit by default — isOperational is the line between 'expected failure' and 'unknown bug'.
Production Insight
Without custom error classes, global handlers match on error.message strings — fragile, breaks when messages are reworded, and cannot be caught by instanceof.
In TypeScript codebases (standard in 2026), custom error classes give you compile-time type safety on the catch block — the TypeScript compiler can tell you if you forgot to handle a new error type.
ExternalServiceError is worth adding to your error class hierarchy early — distinguishing your own bugs from upstream service failures is critical for correct SLA attribution and incident response.
Rule: every error thrown from your service layer should be an instance of a custom error class with statusCode, errorCode, and isOperational. Plain new Error() in service layer code is a code smell.
Key Takeaway
Custom error classes turn errors from opaque strings into structured data with type, HTTP status, machine-readable code, and operational context.
The isOperational flag is the architectural decision that determines whether your process keeps running or restarts after an error — getting this right is the difference between good availability and crash loops.
In TypeScript codebases, custom error classes provide compile-time safety on catch blocks — the type system helps you handle every error type your service can produce.
Never log or expose receivedValue for passwords, tokens, or PII — include it only for debugging non-sensitive validation failures.
Express Global Error Middleware — One Handler to Rule Them All
In a real Express API, you could put try/catch blocks in every single route handler and write error response logic inline wherever an error occurs. But that means duplicating your error formatting in dozens of places. When you need to change how errors are structured — switching to RFC 7807 (Problem Details for HTTP APIs), adding a request ID to every error response, or integrating a new observability platform — you are editing dozens of files. That approach does not scale.
Express has a built-in mechanism for centralised error handling: a middleware function with exactly four arguments (err, req, res, next). Express detects the four-argument signature at registration time using function.length and treats it as an error-handling middleware, only invoking it when next(error) is called somewhere upstream. This is the correct pattern: route handlers use try/catch and call next(error) on failure, and one global handler at the bottom of your middleware stack owns all error formatting, logging, and response decisions.
The critical behaviour that trips up almost everyone: Express does not automatically intercept errors thrown inside async route handlers. If an async function throws and nobody calls next(error), Express never sees the error. In Node.js 22, that becomes an unhandled Promise rejection that exits the process. You need the asyncHandler wrapper — a tiny utility that wraps every async route and automatically forwards any rejection to next. This is a one-time infrastructure decision that makes every async route in your application safe.
One change worth noting for 2026: if you are using Express 5 (currently in release candidate and increasingly adopted), Express 5 natively handles Promise rejections from async route handlers — the asyncHandler wrapper is not required. Express 4.x, which remains the dominant version in production, still requires it. Know which version you are on.
import express, { Request, Response, NextFunction, ErrorRequestHandler } from'express';
import { randomUUID } from'crypto';
const app = express();
app.use(express.json({ limit: '1mb' })); // Always set a body size limit// ─────────────────────────────────────────────────────────────────// Custom error classes (abbreviated from previous section)// In a real codebase, import these from a shared errors module// ─────────────────────────────────────────────────────────────────classAppErrorextendsError {
constructor(
message: string,
publicreadonly statusCode: number,
publicreadonly errorCode: string,
publicreadonly isOperational = true
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
classNotFoundErrorextendsAppError {
constructor(resourceType: string, resourceId: string | number) {
super(`${resourceType} with id '${resourceId}' does not exist`, 404, 'NOT_FOUND');
}
}
classValidationErrorextendsAppError {
constructor(message: string, publicreadonly fieldName: string) {
super(message, 422, 'VALIDATION_ERROR');
}
}
// ─────────────────────────────────────────────────────────────────// asyncHandler — the wrapper that makes Express safe for async routes// Needed for Express 4.x. Not needed in Express 5 (which handles// Promise rejections natively). Know which version you are on.// ─────────────────────────────────────────────────────────────────const asyncHandler = (routeFn: (req: Request, res: Response, next: NextFunction) => Promise<void>) => {
return (req: Request, res: Response, next: NextFunction): void => {
Promise.resolve(routeFn(req, res, next)).catch(next);
// If routeFn rejects, .catch(next) forwards the error to Express error middleware// Without this: unhandled rejection → process exits in Node.js 22
};
};
// ─────────────────────────────────────────────────────────────────// Request ID middleware — attach before routes, use in error responses// Gives every request a traceable ID through logs, error responses, and APM// ─────────────────────────────────────────────────────────────────
app.use((req: Request, res: Response, next: NextFunction) => {
(req asany).requestId = randomUUID();
res.setHeader('X-Request-Id', (req asany).requestId);
next();
});
// ─────────────────────────────────────────────────────────────────// Simulated data layer// ─────────────────────────────────────────────────────────────────const userStore: Record<string, { id: string; name: string; role: string }> = {
'101': { id: '101', name: 'Alice Nakamura', role: 'admin' },
'202': { id: '202', name: 'Ben Okafor', role: 'viewer' },
};
asyncfunctiongetUserById(userId: string) {
const user = userStore[userId];
if (!user) thrownewNotFoundError('User', userId);
return user;
}
// ─────────────────────────────────────────────────────────────────// Route handlers — clean, no error formatting here// Each handler focuses on the happy path and throws typed errors on failure// ─────────────────────────────────────────────────────────────────
app.get('/users/:userId', asyncHandler(async (req, res) => {
const user = awaitgetUserById(req.params.userId);
res.status(200).json({ success: true, data: user });
}));
app.post('/users', asyncHandler(async (req, res) => {
const { email, name } = req.body;
if (!email || typeof email !== 'string' || !email.includes('@')) {
thrownewValidationError('A valid email address is required', 'email');
}
// Simulate create — in real code, this is a database callconst newUser = { id: randomUUID(), name: name || 'Unknown', role: 'viewer' };
res.status(201).json({ success: true, data: newUser });
}));
// Route that simulates an unexpected bug (non-operational error)
app.get('/debug/crash', asyncHandler(async (_req, _res) => {
// This represents a genuine bug — accessing a property on undefined, etc.thrownewError('Unexpected internal error — this is not an AppError');
}));
// ─────────────────────────────────────────────────────────────────// Global error handler — MUST be the last app.use() call// Express identifies this as an error handler via the 4-argument signature// Express checks function.length at registration — do not use arrow functions// with default params or rest params as they change .length// ─────────────────────────────────────────────────────────────────const globalErrorHandler: ErrorRequestHandler = (err, req, res, _next) => {
const requestId = (req asany).requestId || 'unknown';
const timestamp = newDate().toISOString();
// Always log the full error — operational or not
console.error(JSON.stringify({
timestamp,
requestId,
errorName: err.name,
errorCode: err.errorCode || 'UNKNOWN',
message: err.message,
statusCode: err.statusCode || 500,
isOperational: err.isOperational || false,
stack: err.stack,
url: req.url,
method: req.method,
}));
if (err.isOperational) {
// Expected error — respond with structured error, keep process runningreturn res.status(err.statusCode).json({
success: false,
requestId, // Lets clients correlate error reports with your logs
error: {
code: err.errorCode,
message: err.message,
// For ValidationError: include the field name so the client knows what to fix
...(err.fieldName && { field: err.fieldName }),
},
});
}
// Non-operational error — this is a bug// Respond generically: never leak internal details to clients// Alert your on-call team here: Sentry.captureException(err), PagerDuty, etc.return res.status(500).json({
success: false,
requestId,
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred. Our team has been notified.',
},
});
};
app.use(globalErrorHandler);
// ─────────────────────────────────────────────────────────────────// Process-level safety nets — last resort, not primary error handling// ─────────────────────────────────────────────────────────────────
process.on('unhandledRejection', (reason) => {
// In Node.js 22, this handler fires before the default process exit// Use it to flush logs and alert — do not suppress the exit
console.error('UNHANDLED_REJECTION:', reason);
// Sentry.captureException(reason);
});
process.on('uncaughtException', (error) => {
console.error('UNCAUGHT_EXCEPTION — process will restart:', error.stack);
// Give logging transports 1 second to flush before exitingsetTimeout(() => process.exit(1), 1000);
});
app.listen(3000, () => console.log('Server running on port 3000 — Node.js', process.version));
Output
Server running on port 3000 — Node.js v22.11.0
# GET /users/999 → 404
{"timestamp":"2026-03-05T10:23:01.442Z","requestId":"a1b2c3d4-...","errorName":"NotFoundError","errorCode":"NOT_FOUND","message":"User with id '999' does not exist","statusCode":404,"isOperational":true,...}
Response: {"success":false,"requestId":"a1b2c3d4-...","error":{"code":"NOT_FOUND","message":"User with id '999' does not exist"}}
# POST /users with invalid email → 422
{"timestamp":"2026-03-05T10:23:05.001Z","requestId":"e5f6g7h8-...","errorName":"ValidationError","errorCode":"VALIDATION_ERROR","message":"A valid email address is required","statusCode":422,"isOperational":true,...}
Response: {"success":false,"requestId":"e5f6g7h8-...","error":{"code":"VALIDATION_ERROR","message":"A valid email address is required","field":"email"}}
Response: {"success":false,"requestId":"i9j0k1l2-...","error":{"code":"INTERNAL_ERROR","message":"An unexpected error occurred. Our team has been notified."}}
Express 4 vs Express 5 — The asyncHandler Requirement Changed
Express 5 (currently in release candidate and increasingly adopted in 2026) handles Promise rejections from async route handlers natively — if an async route function rejects, Express 5 forwards the rejection to your error middleware automatically without an asyncHandler wrapper. Express 4.x does not do this — async route rejections become unhandled Promise rejections. Know which version your application runs. If you are migrating from Express 4 to Express 5, you can remove asyncHandler wrappers after upgrading, but leaving them in place is also harmless. The bigger risk is starting a new Express 4 project in 2026 without asyncHandler and not knowing why async routes behave unpredictably under load.
Production Insight
Including requestId in every error response is one of the highest-leverage changes you can make to your error handling infrastructure.
When a user reports an error, they can give you the requestId from the response body, and you can find the complete request context in your logs in seconds — without asking them to reproduce the issue.
In Node.js 22 with the AsyncLocalStorage API (stable), you can propagate the requestId through the entire async call stack automatically, so it appears in every log line related to that request without explicitly passing it through every function.
Rule: every error response must include the HTTP status code, a machine-readable errorCode, a human-readable message, and a requestId that correlates to your log aggregator.
Key Takeaway
Express error middleware must have exactly four arguments (err, req, res, next) and be registered as the very last app.use() call — no exceptions.
In Express 4, async routes silently swallow rejections without asyncHandler. In Express 5, this is handled natively — know which version you are running.
Include requestId in every error response — it enables instant log correlation when users report issues.
One global handler with instanceof routing replaces dozens of per-route try/catch blocks and is the only maintainable approach at scale.
Express Error Handler — Common Misconfigurations
IfGlobal error handler is registered before routes
→
UseMove it to the very last app.use() call. Express processes middleware in registration order — errors from routes registered after the handler will never reach it.
IfAsync route throws but global error handler never fires
→
UseThe route is not wrapped with asyncHandler (Express 4) or you are not on Express 5 yet. Wrap with: asyncHandler(async (req, res) => { ... })
IfError handler receives the right errors but sends wrong status codes
→
UseThe handler is not using instanceof to route by error class. Add explicit instanceof checks for each custom error type before the generic fallback.
IfError handler uses exactly four arguments but still does not catch errors
→
UseCheck function.length — arrow functions with default parameters or rest parameters have a different .length. Use a named function or the ErrorRequestHandler TypeScript type to ensure Express detects it correctly.
Async Error Patterns in Practice — Avoiding the Swallowed Error Trap
The most insidious Node.js bugs are the ones where an error happens, nothing crashes, nothing logs, and nothing works. This is the swallowed error — a rejection that gets silently discarded because it happened inside a Promise chain without a .catch(), or inside an async function that was called without await. In production, this manifests as missing database rows, undelivered emails, incomplete payment records, and confused users who see a success confirmation for an operation that never completed.
There are three specific patterns that cause this in real codebases. First, calling an async function without await and without handling the returned Promise — the function runs, fails, and nothing outside it knows. Second, using Promise.all without understanding that one rejection cancels the remaining operations but still needs a catch. Third, attaching .catch() to the wrong segment of a Promise chain — catching the setup code but not the async operation itself.
The solution is not complicated. It is disciplined. Every async function call either gets awaited inside a try/catch block, or gets a .catch() chained directly on the call site. No exceptions. No 'I'll add error handling later'. The ESLint rule @typescript-eslint/no-floating-promises enforces this automatically in CI — it catches unawaited async calls as a build error, which is exactly how teams with mature Node.js codebases prevent this entire class of production incident from shipping.
Promise.any is worth knowing in 2026 for a specific use case: when you have multiple sources for the same data and you want the first one that succeeds, ignoring failures from the others. It is the complement to Promise.all — instead of requiring all to succeed, it requires any one to succeed. If all fail, it throws an AggregateError containing all rejection reasons. This is useful for redundant API calls, fallback cache lookups, and geographic routing.
Promise.all vs Promise.allSettled vs Promise.any — The Decision That Matters
Promise.all: all must succeed, any failure cancels everything. Use for all-or-nothing operations where partial results cannot be used — generating a report that requires complete data, executing a multi-step transaction. Promise.allSettled: wait for all, regardless of individual outcomes. Use for parallel independent operations where partial success is acceptable — dashboard widgets, batch processing, pre-fetching multiple resources. Promise.any: first success wins, individual failures are ignored. Use for redundant sources — cache tiers, geographic API fallbacks, A/B backend routing. Only throws if every source fails, giving you an AggregateError with all reasons. Choosing between these three is one of the most impactful correctness decisions in async Node.js code.
Production Insight
An unawaited async function is the most dangerous bug class in Node.js — in Node.js 22, it exits the process with no warning, no grace period.
The void keyword is the correct way to mark intentional fire-and-forget operations when you genuinely do not need the result — it tells ESLint no-floating-promises that you made a deliberate choice.
Promise.any was added in Node.js 15 and is fully standard in Node.js 22 — it is underused in production code despite being the perfect solution for cache-tier lookups and redundant API patterns.
Rule: @typescript-eslint/no-floating-promises in CI. Every async call must be awaited, .catch()-chained, or explicitly marked void with a documented reason.
Key Takeaway
The swallowed error — no crash, no log, missing data — is the most damaging Node.js bug class in production.
Every async call must be awaited in try/catch, chained with .catch(), or explicitly marked void. No exceptions.
Promise.allSettled for independent parallel operations, Promise.all for all-or-nothing flows, Promise.any for first-success-wins patterns with fallbacks.
The void keyword marks intentional fire-and-forget — use it explicitly so ESLint no-floating-promises knows you made a deliberate choice.
Process-Level Safety Nets — uncaughtException and unhandledRejection
Even with disciplined middleware, custom error classes, and the no-floating-promises ESLint rule enabled in CI, things can still slip through to the process level. A third-party library can throw from an internal callback you never anticipated. A native addon can produce an uncatchable error. A Promise can be created and rejected in a code path that runs after all your middleware has already executed. Node.js provides two process-level event handlers as your absolute last line of defense: uncaughtException and unhandledRejection.
These are not error handling strategies. They are crash containment. The moment an uncaughtException fires, your process is in an undefined state. Memory may be corrupted, connections may be half-open, database transactions may be incomplete, in-flight requests may be lost. The correct response in almost every production scenario is to log the error with full context, send an alert to your on-call system, give logging transports time to flush, and exit the process so your process manager — PM2, systemd, a Kubernetes restart policy — can start a clean instance.
unhandledRejection is subtly different. It fires when a Promise is rejected and no .catch() handler is attached within the same microtask turn. In Node.js 14 and below, this was a deprecation warning. In Node.js 15 and above — including Node.js 22 — unhandledRejection throws by default, making it functionally equivalent to uncaughtException. The default behaviour in Node.js 22 is exactly correct: if you have a rejected Promise that nothing is handling, the process should exit. This is not aggressive — it is the right answer. A process that silently ignores rejected Promises is hiding bugs, not being resilient.
In Node.js 22, the AsyncLocalStorage API (fully stable) lets you propagate context — request ID, user ID, trace ID — through the async call stack automatically. This means your process-level handlers can log full request context even for errors that escape the request lifecycle, which dramatically improves the debuggability of crashes.
import express from'express';
import { AsyncLocalStorage } from 'async_hooks'; // Stable in Node.js 22import { randomUUID } from'crypto';
const app = express();
// ─────────────────────────────────────────────────────────────────// AsyncLocalStorage — propagates request context through the entire// async call stack automatically, including into error handlers// No prop drilling, no globals, no context passed through every function// ─────────────────────────────────────────────────────────────────const requestContext = newAsyncLocalStorage<{ requestId: string; userId?: string }>();
app.use((req, _res, next) => {
const context = { requestId: randomUUID() };
// Everything inside this callback — including all async operations it triggers —// has access to this context via requestContext.getStore()
requestContext.run(context, next);
});
// ─────────────────────────────────────────────────────────────────// Register process-level handlers BEFORE any other code runs// These are last-resort crash containment — not primary error handling// ─────────────────────────────────────────────────────────────────
process.on('unhandledRejection', (reason: unknown, promise: Promise<unknown>) => {
// In Node.js 22, this fires immediately before the default process exit// Use it to log with full context, alert, and flush — do not suppress the exit
const context = requestContext.getStore(); // Works even in async callbacks
console.error(JSON.stringify({
event: 'UNHANDLED_REJECTION',
timestamp: newDate().toISOString(),
requestId: context?.requestId || 'no-request-context',
reason: reason instanceofError ? {
name: reason.name,
message: reason.message,
stack: reason.stack,
} : String(reason),
}));
// Send to your observability platform before the process exits:// await Sentry.captureException(reason);// await datadogMetrics.increment('app.unhandled_rejection');// In Node.js 22, the process exits after this handler regardless.// Do NOT call process.exit() here — Node.js 22 handles it.
});
process.on('uncaughtException', (error: Error, origin: string) => {
// origin is 'uncaughtException' or 'unhandledRejection' (in older Node versions)// Node.js 22 passes this to help distinguish the sourceconst context = requestContext.getStore();
console.error(JSON.stringify({
event: 'UNCAUGHT_EXCEPTION',
timestamp: newDate().toISOString(),
origin,
requestId: context?.requestId || 'no-request-context',
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
}));
// NEVER attempt to recover and continue after uncaughtException// The process state is undefined — memory may be corrupted// Give logging transports time to flush, then exit// Your process manager (PM2, systemd, K8s) will start a clean instance
console.error('Process will exit in 1 second to allow log flushing.');
setTimeout(() => process.exit(1), 1000);
});
// ─────────────────────────────────────────────────────────────────// Simulate: intentional unhandled rejection for verification// In a real app, this means you have a floating Promise — fix it// ─────────────────────────────────────────────────────────────────asyncfunctionriskyDatabaseOperation(): Promise<void> {
thrownewError('Simulated: connection pool exhausted — all 20 connections in use');
}
// Remove the .catch() to see unhandledRejection fire// This is what a floating Promise looks like in productionsetTimeout(() => {
// With .catch(): caught safelyriskyDatabaseOperation().catch((err) => {
console.log('Caught intentionally:', err.message);
});
// Without .catch() (commented out to avoid crashing the demo):// riskyDatabaseOperation(); // ← This triggers unhandledRejection in Node.js 22
}, 500);
app.get('/health', (_req, res) => {
res.json({
status: 'healthy',
uptime: Math.floor(process.uptime()),
nodeVersion: process.version,
memoryMB: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
});
});
app.listen(3000, () => {
console.log(`Server running on port 3000 — Node.js ${process.version}`);
});
Output
Server running on port 3000 — Node.js v22.11.0
# When a floating Promise exists (unhandledRejection fires):
{"event":"UNHANDLED_REJECTION","timestamp":"2026-03-05T10:30:00.001Z","requestId":"no-request-context","reason":{"name":"Error","message":"Simulated: connection pool exhausted — all 20 connections in use","stack":"Error: Simulated..."}}
Process Handlers Are the Fire Alarm — Not the Fire Extinguisher
uncaughtException: something bypassed every try/catch in your codebase. Process state is undefined. Log everything, give logging time to flush, exit with code 1. Never try to recover.
unhandledRejection: a Promise was rejected and nothing caught it. In Node.js 22, this exits the process by default — correct behaviour. Fix the floating Promise; do not suppress the signal.
AsyncLocalStorage (stable in Node.js 22): propagates request context through the entire async call stack — request ID is available in process-level handlers even for errors that escape the request lifecycle.
These are your last line of defense, not your primary error handling strategy. If they fire regularly, you have a systematic gap to close.
Always give logging transports 1 second to flush before process.exit(1). A crash with no log is worse than a slow crash with a complete record.
Production Insight
After an uncaughtException, the Node.js documentation explicitly states: 'It is not safe to resume normal operation after uncaughtException.' This is not a suggestion — it is a description of the runtime state.
In Kubernetes, let the pod crash and restart: liveness probe failures trigger pod replacement faster and cleaner than attempting in-process recovery.
AsyncLocalStorage in Node.js 22 solves the 'I can see the crash but I cannot tell which request caused it' problem that has plagued Node.js debugging for years — adopt it for request tracing from day one.
Rule: if unhandledRejection fires in production more than once per day, you have floating Promises that need to be found and fixed. Use the ESLint rule, not handler suppression.
Key Takeaway
uncaughtException and unhandledRejection are crash containment infrastructure, not error handling strategies — if they fire regularly, your error handling has structural gaps.
After uncaughtException, the process state is undefined — log, alert, exit. Never attempt to continue.
In Node.js 22, unhandledRejection exits the process by default — this is correct. Fix the floating Promise; do not suppress the exit signal.
Use AsyncLocalStorage (stable in Node.js 22) to propagate request context through the full async stack — request IDs in crash logs transform debugging from archaeology into investigation.
● Production incidentPOST-MORTEMseverity: high
The Unawaited Promise That Silently Killed 40% of Payment Webhooks
Symptom
Customer support tickets spiked reporting missing order confirmations after payments that the gateway confirmed as successful. The payment gateway dashboard showed 100% webhook delivery success. Our API returned HTTP 200 on every webhook request. But 40% of orders had no corresponding database record — the webhook handler was receiving the payload, processing it, responding with 200, and silently discarding the database write. From the outside, everything looked healthy.
Assumption
The team assumed the payment gateway was sending duplicate or malformed webhook payloads. Three hours were spent in the gateway's dashboard examining delivery logs, retry queues, and payload structures. Everything on the gateway side was exactly correct. The investigation then shifted to network middleware and request parsing — another two hours lost chasing the wrong layer.
Root cause
A developer had refactored the webhook handler from synchronous database calls to async/await the week before the incident. One critical line changed from a synchronous ORM call to an async one — but the call site was not updated to include await. The function returned a Promise instead of a resolved value. The handler did not await the insert. The Promise was created, the database write was scheduled, and the handler immediately returned 200 to the payment gateway. Under normal load, the write completed before the Node.js event loop recycled the connection. Under the traffic spike that triggered the incident, the connection pool was exhausted, the unresolved Promises queued behind pool availability, and Node.js garbage collected them before they resolved — because nothing held a reference to the Promise chain. No error was thrown because the Promise never rejected. The database simply never received the write.
Fix
Added the ESLint rule @typescript-eslint/no-floating-promises to CI configuration — any async function call without await, void, or .catch() is now a build failure that blocks deployment. Added a post-write verification step in the webhook handler: after every database insert, immediately query by the generated ID and confirm the row exists before returning 200 to the gateway. If the row count is zero after an insert, the handler returns 500 so the gateway retries delivery. Added structured logging around every database operation with operation type, affected row count, and execution duration as indexed fields in the log aggregator. Configured an alert on any webhook handler response where row count is zero.
Key lesson
An unawaited async function is the most dangerous bug class in Node.js — it produces zero errors, zero logs, zero crashes, and zero indication that anything went wrong. The data simply does not arrive.
ESLint's @typescript-eslint/no-floating-promises rule is not optional in production codebases — it catches exactly this class of bug that code review consistently misses because the code looks syntactically correct.
Trust but verify: after critical writes, read back the data to confirm it persisted before responding with 200. A 200 response means nothing if the write did not land.
If your webhook handler returns 200 before confirming the side effect completed, you have a permanent data loss vector that will activate under load.
Three hours of investigation in the wrong layer is expensive. When data disappears silently, the first question is always: is there an unawaited async call in this code path?
Production debug guideSymptom-driven actions for diagnosing silent failures, swallowed errors, and process crashes in production5 entries
Symptom · 01
UnhandledPromiseRejectionWarning appears in logs but the application appears functional
→
Fix
Do not treat this as a warning. In Node.js 15+ (including Node.js 22), this will crash the process by default — it is a timer on your stability. Search your codebase for async function calls without await: grep -rn 'async\|Promise' src/ and cross-reference with every async function invocation site. Enable the ESLint rule @typescript-eslint/no-floating-promises to catch these automatically in CI before they reach production. The floating Promise is the source — find it and add await or .catch().
Symptom · 02
Express route returns 200 but the expected side effect (database write, email, event) never happened
→
Fix
The async route handler is almost certainly not wrapped with an asyncHandler utility. Express does not intercept rejections from async route handlers — the rejection becomes an unhandled Promise rejection while Express has already sent the 200 response. Audit every async route: grep -rn 'async (req' src/routes/ | grep -v asyncHandler. Wrap every match with asyncHandler: const asyncHandler = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next). This is a one-time fix per route file.
Symptom · 03
Process crashes with 'Uncaught Exception' pointing to a deeply nested call stack
→
Fix
An error bypassed all try/catch blocks and reached the process level. Capture the full stack trace immediately — in Node.js 22, the Error.stack property includes async frames if the code uses async/await, making the origin significantly easier to trace than in older versions. Check whether the error is operational (expected, has isOperational: true) or a genuine bug. If it is a bug, the correct response is to log everything, alert, and let the process manager restart. Do not attempt to recover and continue.
Symptom · 04
Global error handler sends 500 for errors that should be 422 (validation) or 404 (not found)
→
Fix
The global handler is not using instanceof to route by error type — it is treating all errors as generic Error instances and falling through to the 500 default. Implement custom error classes (ValidationError, NotFoundError, ServiceUnavailableError) that carry statusCode, errorCode, and isOperational. Use instanceof checks in the global handler. If your error classes come from a shared package in a monorepo, verify they share the same class reference — instanceof fails across separate package boundaries.
Symptom · 05
Promise.all rejects and you only see the first error — all other failures are invisible
→
Fix
Promise.all fails fast on the first rejection and discards remaining results. Switch to Promise.allSettled to capture all outcomes. Iterate the results array and handle each {status: 'fulfilled', value} and {status: 'rejected', reason} independently. Reserve Promise.all for genuine all-or-nothing flows where partial results cannot be used. Choosing wrong between these two is responsible for a significant number of dashboard and batch processing bugs.
★ Error Handling Quick DebugFast symptom-to-action reference for error handling issues in production Node.js services. Check in the order listed — most issues are caught by the first or second command.
UnhandledPromiseRejectionWarning in stderr — or process unexpectedly exiting−
Immediate action
Find the floating Promise — an async call without await or .catch() somewhere in the execution path
Add await before the async call and wrap in try/catch, or chain .catch() directly on the call site. Enable ESLint @typescript-eslint/no-floating-promises in your CI pipeline to prevent this class of bug from shipping again.
Express async route silently swallows error — 200 returned but side effect missing+
Immediate action
Verify every async route handler is wrapped with asyncHandler — unwrapped async routes are invisible to Express error handling
Wrap all async route handlers: const asyncHandler = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next). Apply to every match from the second command. Consider a base router class that wraps automatically so individual route files cannot accidentally skip it.
Error handler returns wrong HTTP status code for known error types+
Immediate action
Check whether the global handler uses instanceof to route by error class — string matching on error.message is fragile and breaks when messages change
Implement custom error classes with statusCode and errorCode. In the global handler: if (err instanceof ValidationError) return res.status(422)... if (err instanceof NotFoundError) return res.status(404)... Never switch on error.message.
Process exits on unhandledRejection in Node.js 15+ — crashes during deployment or under load+
Immediate action
Add process-level handlers at the application entry point before any async code executes
Add handlers at app entry point: process.on('unhandledRejection', (reason, promise) => { logger.error({ reason, promise }, 'Unhandled rejection'); }). In Node.js 22, this is correct behaviour — the process should exit on unhandled rejections. Fix the floating Promise in your code; do not suppress the signal with --unhandled-rejections=none.
Error Catching Approaches Compared
Aspect
Promise .catch() Chaining
async/await try/catch
Readability
Moderate — chains can become deeply nested, especially with multiple .then() steps
High — reads like synchronous code, familiar to engineers from any background
Error origin clarity
Can be unclear which step in a long chain produced the rejection
Stack trace points to the exact await line that threw — significantly easier to debug
Multiple sequential operations
Requires careful chain construction to avoid pyramid of doom
Sequential steps are naturally clear and readable in linear order
Parallel execution
Natural fit with Promise.all().catch() or Promise.allSettled()
Works well — wrap Promise.all or Promise.allSettled in try/catch
Partial error handling per step
Each .catch() in the chain catches rejections from above it only
Multiple try/catch blocks needed — can be verbose for many independent steps
Works in non-async functions
Yes — any function that returns a Promise can chain .catch()
No — requires the function to be declared async, which changes its return type
ESLint enforcement
.catch() is easy to accidentally omit — the call site looks the same either way
@typescript-eslint/no-floating-promises catches unawaited calls as a build error
Express 5 compatibility
Works identically in Express 4 and 5
In Express 5, async route rejections are forwarded to error middleware natively — asyncHandler wrapper not required
Best for
Utility functions, fire-and-forget with void, simple async transformations, non-async function contexts
Business logic, route handlers, service layer functions, anywhere readability and debuggability matter most
Key takeaways
1
Node.js has four error channels
synchronous throw, error-first callback, Promise rejection, and EventEmitter 'error' event. Each requires a different catching mechanism. Missing any one means silent failures that only surface under production load.
2
Custom error classes with isOperational, statusCode, errorCode, and contextual properties turn your global handler from a guessing game into a type-based router. isOperational is the single decision that determines whether your process keeps running or restarts after an error.
3
In Express 4, async routes silently swallow errors without an asyncHandler wrapper. In Express 5, async rejections are forwarded to error middleware natively. Know which version you are on
this difference causes the most common Express production bug in 2026.
4
Every async call must be either awaited inside try/catch, chained with .catch(), or explicitly marked void with a documented reason. An unawaited async function produces zero errors, zero logs, and zero crashes in Node.js
data is silently lost.
5
In Node.js 22, unhandledRejection exits the process with code 1 by default. This is correct. Fix the floating Promise; do not suppress the exit signal with --unhandled-rejections=warn.
6
uncaughtException means the process state is undefined
log, alert, exit with process.exit(1). Never attempt to recover and continue serving requests after an uncaughtException.
7
Promise.allSettled for independent parallel operations where partial success is acceptable. Promise.all for all-or-nothing flows. Promise.any for first-success-wins patterns with redundant sources. Choosing wrong causes either silent partial failures or unnecessarily broken interfaces.
8
AsyncLocalStorage (stable in Node.js 22) propagates requestId through the full async call stack automatically
include it in every error response and log line so engineers can trace any error back to its exact request in under 30 seconds.
Common mistakes to avoid
6 patterns
×
Catching errors in callbacks but not returning after the error branch
Symptom
Code continues executing after the error branch — in Express this causes 'Cannot set headers after they are sent to the client'. In data processing code it causes corrupted output because downstream code runs with undefined or null values from the failed operation.
Fix
Always return immediately after handling a callback error: if (err) { handleError(err); return; }. The return is not optional styling — it is the mechanism that stops execution. Treat this as a mandatory two-line pattern. In Node.js 22, prefer fs/promises over the callback-based fs API for new code — it eliminates this pattern entirely by using Promise rejections instead.
×
Using try/catch around an async function call without awaiting it
Symptom
The try/catch block never triggers even though the async function clearly throws. The error surfaces later as an UnhandledPromiseRejection that exits the process in Node.js 22. The try/catch catches the synchronous act of calling the function, not its eventual rejection.
Fix
Use try { await asyncFunction(); } not try { asyncFunction(); }. The await keyword is what makes the try/catch relevant — without it, you are catching the act of creating a Promise, not its outcome. Enable @typescript-eslint/no-floating-promises in your CI pipeline to catch this class of mistake before it ships.
×
Registering the Express global error handler before routes
Symptom
Errors from routes are never handled by the global middleware. Errors either propagate as unhandled rejections (crashing in Node.js 22) or return as raw HTML error pages from Express's default error handler. The four-argument middleware is registered and functional — just unreachable.
Fix
The global error handler must be the very last app.use() call, after all routes and all other middleware. Express processes middleware in registration order — a handler registered at position 2 never sees errors from routes registered at position 5 and beyond.
×
Throwing plain strings or plain objects instead of Error instances
Symptom
Error handlers receive raw strings like 'User not found' with no stack trace, no error type, and no way to check instanceof. Stack traces are completely absent — debugging requires parsing message strings. Structured logging cannot categorise the error correctly.
Fix
Always throw new Error() or an instance of a custom error class. Never throw a plain string (throw 'failed'), a plain object (throw { code: 'ERR' }), or a number. Plain throws lose the stack trace entirely and break every instanceof check in your error handling chain.
×
Using Promise.all when Promise.allSettled is the correct choice for independent operations
Symptom
A dashboard that loads five data sources from five different APIs shows a full-page error when one API times out. A batch processing job that handles 1,000 records aborts entirely when record 47 fails, discarding all progress on the remaining 953 records.
Fix
Use Promise.allSettled when each result is independent and partial success is acceptable — dashboards, batch jobs, parallel data loading, pre-fetching. Use Promise.all only when all results are required to proceed and one failure genuinely should cancel everything. This is one of the most impactful correctness decisions in async Node.js code.
×
Not including requestId in error responses and error logs
Symptom
A user reports an error. You know the time range. You cannot find the specific request in your logs because there is nothing to correlate the user's experience with a specific log entry. You end up asking the user to reproduce the issue, which they often cannot do.
Fix
Generate a UUID per request in middleware (randomUUID() from the crypto module — no external dependency in Node.js 22), attach it to the request object, include it in every error response, and include it in every log line using AsyncLocalStorage. When a user reports an error, the requestId from their error response finds the complete request trace in under 30 seconds.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
What are the four ways Node.js delivers errors, and how does each requir...
Q02SENIOR
What is the isOperational flag on custom error classes, and why does it ...
Q03SENIOR
Why does Express 4 require an asyncHandler wrapper for async route handl...
Q04SENIOR
What is the difference between uncaughtException and unhandledRejection,...
Q05SENIOR
When would you use Promise.allSettled instead of Promise.all, and what h...
Q06SENIOR
How do you structure a global error handler in Express that handles oper...
Q01 of 06SENIOR
What are the four ways Node.js delivers errors, and how does each require different handling?
ANSWER
Node.js delivers errors through four channels, each requiring a different mechanism to catch.
First, synchronous throws — caught by try/catch, the same as any language. JSON.parse, URL constructor, and synchronous validators all use this channel.
Second, error-first callbacks — the first argument to the callback is an Error or null. You must check it and return immediately after handling, or execution falls through to the success path with undefined data. The fs module's callback-based API uses this pattern.
Third, Promise rejections — caught by .catch() or await inside try/catch. In Node.js 22, unhandled rejections exit the process with code 1 by default. Modern ORMs, fetch(), and fs/promises use this channel.
Fourth, EventEmitter 'error' events — caught by .on('error', handler). No handler attached means uncaught exception, which also exits the process. Streams, HTTP servers, and database connection pools use this channel.
The critical skill is identifying which channel a library uses before integrating it. Error handling code that targets the wrong channel compiles correctly, passes tests, and catches nothing in production.
Q02 of 06SENIOR
What is the isOperational flag on custom error classes, and why does it matter for production stability?
ANSWER
The isOperational flag distinguishes between errors you anticipated and errors that are genuine bugs.
Operational errors (isOperational: true) are expected failures in the normal course of operation: validation failures, not-found responses, rate limit breaches, external service timeouts, database connection limits. When these occur, the correct response is to log them, respond to the client with the appropriate HTTP status and error code, and keep the process running. The application is functioning correctly — it is handling an expected failure mode.
Non-operational errors (isOperational: false or missing) are bugs: null pointer dereferences, type errors, corrupted internal state, unexpected thrown values from third-party libraries. When these occur, the correct response is to log with full stack trace, alert the on-call team, and restart the process — because the process state may be undefined and continuing to serve requests risks data corruption and unpredictable behaviour.
In Node.js 22, this distinction is more important than ever because unhandledRejection exits the process by default. isOperational is the precise line between 'this is handled expected behaviour' and 'this is an unknown bug that requires investigation'. Without it, you either crash on every validation error or silently continue after a genuine memory corruption bug.
Q03 of 06SENIOR
Why does Express 4 require an asyncHandler wrapper for async route handlers, and what changed in Express 5?
ANSWER
Express 4 was designed before async/await existed. Its internal error routing relies on synchronous try/catch and the next(error) callback pattern. When an async route handler throws or returns a rejected Promise, Express 4 does not intercept the rejection — it escapes Express's middleware chain and becomes an unhandled Promise rejection. In Node.js 22, that exits the process with code 1.
The asyncHandler wrapper solves this by wrapping the route function: Promise.resolve(fn(req, res, next)).catch(next). If the async function rejects, .catch(next) forwards the error to Express's error handling chain via next(error), which reaches your global four-argument error middleware.
Express 5 changes this behaviour: async route handlers that reject are automatically forwarded to error middleware without a wrapper. This is one of the primary motivations for the Express 5 release.
In 2026, both versions are in active production use. If your application runs Express 4, every async route needs the wrapper. If you are on Express 5, the wrapper is not required but leaving it in place is harmless.
Q04 of 06SENIOR
What is the difference between uncaughtException and unhandledRejection, and when should you crash versus continue?
ANSWER
uncaughtException fires when a synchronous error bypasses all try/catch blocks — something that nothing in your code anticipated handling. The process state at this point is undefined: memory may be corrupted, file descriptors may be half-open, database transactions may be incomplete. The Node.js documentation explicitly states that normal operation should not be resumed after an uncaughtException. The correct action is always: log with full context, give logging transports 1 second to flush, process.exit(1). Your process manager restarts a clean instance.
unhandledRejection fires when a Promise is rejected and no .catch() handler is attached within the same microtask turn. In Node.js 14 and below, this was a deprecation warning. In Node.js 15 and above — including Node.js 22 — it exits the process by default. This is correct behaviour. A process that silently ignores rejected Promises is hiding bugs, not being resilient.
For uncaughtException: always exit. No exceptions. The process state cannot be trusted.
For unhandledRejection in Node.js 22: the process exits by default, which is correct. Your unhandledRejection handler fires first — use it to log and alert before exit. Do not suppress the exit. Fix the floating Promise.
Q05 of 06SENIOR
When would you use Promise.allSettled instead of Promise.all, and what happens if you choose wrong?
ANSWER
Promise.all rejects immediately on the first rejection, discarding all other operations' results and letting them resolve or reject unobserved. This is correct for all-or-nothing scenarios: rendering a page that requires all three API responses, executing a multi-step database transaction where partial success creates inconsistent state.
Promise.allSettled waits for every Promise to settle regardless of individual outcomes, returning an array of objects where each has status: 'fulfilled' and value, or status: 'rejected' and reason. This is correct for independent parallel operations where partial success is acceptable: a dashboard loading five data sources, a batch job processing records, pre-fetching multiple user preferences.
Promise.any (available in Node.js 22) returns the first fulfilled result, ignoring individual failures — use it for redundant sources, cache tier lookups, and geographic API fallbacks.
Choosing wrong: Promise.all for a dashboard means one slow or failing API takes down the entire page with a full-page error, even though four of five data sources are healthy. Promise.allSettled for an all-or-nothing flow means you proceed with partial data and produce corrupted or inconsistent state without any indication that something went wrong.
Q06 of 06SENIOR
How do you structure a global error handler in Express that handles operational errors and bugs differently, and what should it include in 2026?
ANSWER
Register a four-argument middleware (err, req, res, next) as the very last app.use() call in your application. Express identifies error handlers by function.length — exactly four parameters.
The handler should do the following in order:
1. Log the error with full structured context: timestamp, request ID (from AsyncLocalStorage or req object), error name, errorCode, message, HTTP status, isOperational flag, stack trace, request URL, and method. Use structured JSON logging — unstructured logs are unsearchable at scale.
2. If err.isOperational is true: respond with err.statusCode and a structured error body containing errorCode, message, requestId, and any error-type-specific context (fieldName for validation errors, retryAfterSeconds for rate limit errors). The process continues serving requests.
3. If err.isOperational is false or missing: this is a bug. Respond with a generic 500 that leaks no internal details — 'An unexpected error occurred. Our team has been notified.' Send an alert to your observability platform (Sentry, PagerDuty, Datadog) with the full error context. The process should be considered suspect — let the next uncaughtException handler exit cleanly.
4. Route by instanceof before isOperational — use explicit instanceof checks for each custom error class to apply class-specific response logic. Never match on error.message strings.
In Node.js 22, use AsyncLocalStorage to propagate requestId automatically through the full async call stack — it is available in the error handler without being explicitly passed through every function.
01
What are the four ways Node.js delivers errors, and how does each require different handling?
SENIOR
02
What is the isOperational flag on custom error classes, and why does it matter for production stability?
SENIOR
03
Why does Express 4 require an asyncHandler wrapper for async route handlers, and what changed in Express 5?
SENIOR
04
What is the difference between uncaughtException and unhandledRejection, and when should you crash versus continue?
SENIOR
05
When would you use Promise.allSettled instead of Promise.all, and what happens if you choose wrong?
SENIOR
06
How do you structure a global error handler in Express that handles operational errors and bugs differently, and what should it include in 2026?
SENIOR
FAQ · 6 QUESTIONS
Frequently Asked Questions
01
What happens if I throw a plain string instead of an Error instance in Node.js?
You can throw any value in JavaScript — throw 'something failed', throw 42, throw { code: 'ERR' } all work syntactically. But they break everything downstream. A thrown string has no stack trace, no name property, no instanceof relationship to Error, and no message property. Your catch block receives a raw string. You cannot log a stack trace, cannot use instanceof checks, cannot call error.message, and cannot use any error handling pattern that expects an Error instance — which is every pattern covered in this article.
In TypeScript codebases (standard in 2026), thrown non-Error values also break type safety in catch blocks since the caught value is typed as unknown. Always throw new Error() or an instance of a custom error class that extends Error. The stack trace alone is worth the four extra characters.
Was this helpful?
02
Should I use try/catch or .catch() for Promise error handling?
Use try/catch with await for business logic and route handlers — it reads synchronously, the stack trace points to the exact await line that threw, and TypeScript types the caught value as unknown (forcing you to handle it properly). Use .catch() chaining for utility functions, fire-and-forget operations marked with void, or when you are in a non-async function context that cannot use await.
In Express 4, always use try/catch inside async route handlers wrapped with asyncHandler. In Express 5, you still use try/catch inside async routes, but the asyncHandler wrapper is optional since Express 5 handles async rejections natively.
The absolute rule: never leave an async call with neither try/catch nor .catch(). In Node.js 22, the result is a process exit with no warning.
Was this helpful?
03
Why does Express require exactly four arguments for error middleware?
Express uses Function.prototype.length to detect middleware type at registration time. A function with four parameters — (err, req, res, next) — is identified as error-handling middleware and is only invoked when next(error) is called. A function with three or fewer parameters is treated as regular request middleware.
If you accidentally write (req, res, next) instead of (err, req, res, next), Express registers it as regular middleware and your errors never reach it. This is a real bug that is hard to spot in code review because the function body may look correct.
In TypeScript, use the ErrorRequestHandler type from @types/express to get compile-time enforcement of the correct signature. In JavaScript, count your parameters before registering error handlers — always include all four even if you do not use next.
Was this helpful?
04
How do I handle errors in Promise.all when I need most results but can tolerate some failures?
If you need all results and one failure should cancel everything: use Promise.all. If you need all results but want to know which succeeded and which failed independently: use Promise.allSettled. If you need the first result that succeeds and failures from other sources are acceptable: use Promise.any.
For the common case of 'load five things, show what succeeded, gracefully degrade what failed': Promise.allSettled is the correct tool. It always resolves (never rejects), returns an array of settled results, and lets you handle each outcome individually. Iterating the results with a status check per entry gives you full control over degradation strategy — show cached data, display an empty state, offer a retry button — without a full-page error.
Was this helpful?
05
Is it safe to continue running a Node.js process after an uncaughtException?
No. The Node.js documentation is explicit on this point: 'It is not safe to resume normal operation after uncaughtException.' At the point the handler fires, an error bypassed every try/catch block in your codebase. The process state is unknown — open file descriptors may be in inconsistent state, in-flight database transactions may be half-complete, connections may be half-open, and in-flight HTTP responses may have been partially sent.
Attempting to continue serving requests after an uncaughtException risks: serving corrupted data to clients, completing partial database writes, sending duplicate responses, and making the crash significantly harder to debug because the process continues emitting logs that look normal.
The correct action: log the full error and stack trace, give logging transports 1 second to flush (setTimeout(() => process.exit(1), 1000)), and let your process manager restart a clean instance. In Kubernetes, let the pod crash — the restart policy handles it faster and more cleanly than in-process recovery attempts.
Was this helpful?
06
How does Node.js 22 change error handling compared to what I was doing on Node.js 18 or 20?
The fundamental error handling model — four channels, custom error classes, process-level handlers — has not changed between Node.js 18, 20, and 22. What changed:
The throwing unhandledRejection default (introduced in Node.js 15) has been the standard since Node.js 18 LTS — if you were already on 18 or 20, this is not new. Unhandled rejections exit the process. Fix floating Promises.
AsyncLocalStorage became fully stable in Node.js 16 and is well-established in Node.js 22 — if you are not using it for request context propagation, it is worth adopting. It eliminates a whole class of 'I cannot find which request caused this crash' debugging problems.
The diagnostics_channel API is stable in Node.js 22 and enables structured cross-library error observability without monkey-patching third-party code. Useful for OpenTelemetry integration and APM setup.
Native fetch() is stable in Node.js 22 — it uses Promise rejections, the same as any other async API. Error handling for native fetch is identical to handling for node-fetch or axios.
If you are on Node.js 18 LTS (entering maintenance mode in April 2025) or Node.js 20 LTS (active through April 2026), a migration to Node.js 22 LTS is low-risk for error handling code specifically.