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
✦ Definition~90s read
What is Node.js Error Handling?
This article addresses a critical failure mode in Node.js applications: unhandled promise rejections that silently corrupt data at scale. When a promise rejects without a .catch() or await, Node.js (prior to v15) would merely log a warning and continue executing — meaning your database writes, file operations, or API calls could fail without your application ever knowing.
★
Imagine you're a chef running a restaurant kitchen.
The result is partial writes, inconsistent state, and data loss that can affect 40% or more of transactions in production systems under load. This isn't a theoretical edge case; it's a systemic flaw in how most Node.js codebases handle asynchronous errors.
The article systematically breaks down the four distinct error delivery mechanisms in Node.js — synchronous throws, callback errors, event emitter 'error' events, and promise rejections — and shows how missing even one creates a silent failure path. It then provides battle-tested patterns: custom error classes that carry structured metadata (HTTP status codes, error codes, stack traces) instead of plain strings; Express global error middleware that catches every error in one place; correlation IDs that trace errors across microservice boundaries; and error wrapping that preserves the root cause chain.
These aren't academic patterns — they're the difference between a production outage you can debug in minutes versus one that silently corrupts customer data for hours.
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.
Why Unhandled Promise Rejections Corrupt Data
Node.js error handling is the discipline of catching and responding to synchronous and asynchronous failures before they cascade into data loss or crashes. The core mechanic: every thrown exception or rejected promise must be caught by a try/catch, a .catch() handler, or an event listener like process.on('unhandledRejection'). Without it, Node.js terminates the process on uncaught exceptions, and unhandled rejections silently swallow errors — leaving your database in an inconsistent state.
In practice, async error propagation differs from sync: a rejected promise that lacks a .catch() doesn't throw immediately — it waits for garbage collection, then triggers an 'unhandledRejection' event. This delay fools developers into thinking their code is safe. Meanwhile, the error is lost, and downstream operations continue with corrupted data. The key property: unhandled rejections do not crash the process by default (unlike uncaught exceptions), making them silent data corrupters.
Use explicit error boundaries at every async boundary — route handlers, event emitters, and stream pipelines. In production, this means wrapping every async function in try/catch and attaching .catch() to every promise chain. The cost of omission: a single unawaited promise in a payment processing pipeline can double-charge customers or leave orders in limbo. Real systems must treat unhandled rejections as fatal — log them, alert on them, and crash the process to force a clean restart.
Unhandled Rejections Are Not Logged by Default
Node.js 15+ terminates on unhandled rejections, but older versions silently swallow them — your monitoring will show zero errors while data silently corrupts.
Production Insight
A payment microservice lost 40% of orders because a promise in the invoice generation step was never awaited — the error was swallowed, the order marked 'paid', but no invoice was created.
Symptom: customers received 'payment confirmed' emails but no invoice, and support tickets spiked 10x within 2 hours.
Rule: every async function must either be awaited or have a .catch() — never fire-and-forget a promise in production.
Key Takeaway
Unhandled promise rejections do not crash the process by default — they silently corrupt data.
Always attach a .catch() or use try/catch with await — never fire-and-forget a promise.
Treat unhandled rejections as fatal in production: log, alert, and crash to force a clean restart.
thecodeforge.io
Node.js Error Handling Flow for Data Integrity
Nodejs Error Handling
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
// 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);
}
}
// ─────────────────────────────────────────────────────────────────// 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.
[422] Field: email, Message: A valid email address is required
[422] Field: password, Message: Password must be at least 12 characters
[503] Primary database is unreachable
[503] Primary database is unreachable
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.
Express 5 natively handles Promise rejections from async route handlers — the asyncHandler wrapper is not needed. However, Express 4.x, which is still the majority in production, requires it. Check your package.json: if "express" is ^4.x, you need asyncHandler. If ^5.0.0-alpha or ^5.0.0-rc, you don't. The error middleware signature (err, req, res, next) is identical in both versions.
Production Insight
Forgetting asyncHandler on a single Express route creates a silent data loss vector identical to the unawaited Promise bug in the introduction.
In Express 4, the asyncHandler wrapper is not optional — it is infrastructure. Without it, every async route is a time bomb.
When adopting Express 5, do not remove asyncHandler immediately — test that the version actually behaves as documented; early release candidates had bugs.
Rule: centralise error formatting in one handler, never duplicate it per route. If you find yourself writing res.status(400).json(...) more than once, you are doing it wrong.
Key Takeaway
Express global error middleware with four arguments (err, req, res, next) centralises all error formatting, logging, and responses into one place.
In Express 4.x, every async route handler MUST be wrapped with asyncHandler to forward rejections to Express — without it, errors become unhandled Promise rejections that crash the process in Node.js 22.
Express 5 removes the need for asyncHandler, but verify your version before removing the wrapper.
Never duplicate error response logic — use one global handler with instanceof-based routing to send the correct status code and error body for each error type.
Error Correlation IDs — Your Only Hope in a Microservices Blackout
You grep logs across 12 services after a payment failure. Every error is ‘Connection refused’ or ‘Timeout’. Zero context. That’s because you didn’t thread a correlation ID through your async chain. A single UUID per request lets you stitch together a distributed stack trace. Without it, you’re blind in production. Always generate an ID at the HTTP or message boundary. Attach it to every error object. Log it. Pass it downstream via headers or message metadata. When an upstream service crashes, the ID tells you which request caused it. Don’t roll your own UUID generator — use crypto.randomUUID() or a well-tested library. Store the ID in AsyncLocalStorage so it’s available in any function without manual threading. This turns a wall of noise into a searchable slice of time.
If you log errors without a correlation ID, you can't replay a request flow. Add it to every log line from day one.
Key Takeaway
Every error must carry a request-scoped correlation ID — without it, distributed debugging is guesswork.
Error Wrapping — Preserve the Cause, Not Just the Symptom
Your database throws ECONNREFUSED. Your service catches it and throws DatabaseUnavailableError. Great, but you lose the original stack trace. Now you can't tell if it was a DNS resolution failure or a firewall block. Error wrapping means you nest the original error inside your new one. Node.js has no built-in cause chain, so you manually attach the cause property. When you log the wrapped error, iterate the cause chain to show every layer. This is non-negotiable in a service-oriented architecture. The root cause is rarely at the top of the stack. If you flatten errors, you flatten your ability to diagnose. Every custom error class should accept an optional cause parameter. In your logger, walk error.cause recursively. Print the full chain. That’s how you turn a cryptic ‘operation failed’ into ‘PostgreSQL connection pool exhausted’. Stop masking root causes.
ErrorWrapping.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
// io.thecodeforge — javascript tutorialclassDatabaseErrorextendsError {
constructor(message, cause) {
super(message, { cause });
this.name = 'DatabaseError';
}
}
functionprintCauseChain(err) {
let current = err;
while (current) {
console.error(`[${current.name}] ${current.message}`);
current = current.cause;
}
}
// Production usageconst original = newError('connect ECONNREFUSED 10.0.0.1:5432');
const wrapped = newDatabaseError('Cannot fetch user orders', original);
printCauseChain(wrapped);
// Output:// [DatabaseError] Cannot fetch user orders// [Error] connect ECONNREFUSED 10.0.0.1:5432
Output
[DatabaseError] Cannot fetch user orders
[Error] connect ECONNREFUSED 10.0.0.1:5432
Senior Shortcut:
Use the cause property (Node 16.9+) instead of a custom originalError field. Your logger can then traverse it generically.
Key Takeaway
Always wrap errors with a cause property — lose the original stack and you lose the root cause.
Graceful Shutdown — Why a SIGTERM Shouldn't Kill Requests Mid-Flight
You deploy a new version. The orchestrator sends SIGTERM. Your process dies instantly. Every in-flight request gets a ECONNRESET or a broken response. Users see 502s. That’s a production incident you caused by ignoring process signals. A graceful shutdown traps SIGTERM and SIGINT, stops the HTTP server, and gives ongoing requests a deadline to finish. Node.js won't do this for you. You must listen for the signal, call server.close(), then wait for all pending requests to complete. Use a connection counter or server.closeIdleConnections(). Set a hard timeout — say 10 seconds — then force-exit. Why? If your database is down, hanging forever wastes resources. The code below is minimal. In production, add draining of message queues and database pools. A process that ignores SIGTERM is a process that corrupts data. Don't be that team.
If you don't handle SIGTERM, your orchestrator (K8s, Nomad) will kill mid-request. Add a 10-second forced exit to avoid zombie processes.
Key Takeaway
Always trap SIGTERM and SIGINT to allow in-flight requests to finish — otherwise every deploy is a partial outage.
Async Wrapper — Kill Try/Catch Boilerplate Across Every Route
You've seen the pattern: every async handler wrapped in try/catch, repeating the same next(err) line. That's not engineering — that's manual labor. An async wrapper is a one-liner factory that catches rejected promises and forwards them to Express error middleware. Without it, one missing catch in an async route silently swallows the error. Your logs stay clean, your users get a 500, and you lose the root cause. The wrapper fixes that by intercepting the rejection and calling next(err) automatically. It's a single function you apply at route definition time. No more boilerplate, no more memory leaks from unhandled rejections. Production code demands this pattern because it guarantees every async error has an escape hatch. You write the logic, the wrapper handles the fallthrough.
AsyncWrapper.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// io.thecodeforge — javascript tutorialconst asyncWrapper = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
router.get('/orders/:id', asyncWrapper(async (req, res) => {
const order = awaitOrder.findById(req.params.id);
if (!order) {
const err = newError('Order not found');
err.status = 404;
throw err;
}
res.json(order);
}));
// Without the wrapper, the throw would become an unhandled promise rejection
Output
No output — pattern applied in route definition
Senior Shortcut:
Don't wrap every route manually. Create one asyncWrapper in a shared middleware module and import it. Your team will thank you when they add the 50th route.
Key Takeaway
Wrap every async route handler with a promise catcher — your error middleware is useless if the error never reaches it.
API Response Standardization — Stop Shipping Inconsistent Payloads
Your error handler catches the exception. Great. Now what does the client get? One endpoint returns { error: 'not found' }, another returns { message: 'Invalid ID', code: 400 }. Your frontend team rage-quits because they have to guess the shape. Standardize your API response envelope. Every response — success or failure — should follow the same contract: status, data, error, and a correlation ID. This is not optional in a microservices architecture. Your error middleware should map custom error properties into a uniform JSON structure before sending. That means your controller never calls res.json({ msg }) directly. It throws a structured error, and the global handler serializes it. No exceptions (pun intended). The client always knows where to find the error message and which trace to look for in the logs. Ship a consistent envelope, and your API becomes a black box that behaves predictably.
ResponseStandard.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — javascript tutorial// Global error middleware — last middleware in chain
app.use((err, req, res, next) => {
const status = err.status || 500;
const response = {
status,
data: null,
error: {
message: err.message || 'Internal server error',
code: err.code || 'UNKNOWN'
},
correlationId: req.correlationId
};
res.status(status).json(response);
});
// On success, controller sends
res.json({ status: 200, data: { order }, error: null, correlationId: req.correlationId });
Output
{
"status": 404,
"data": null,
"error": {
"message": "Order not found",
"code": "NOT_FOUND"
},
"correlationId": "abc-123-def"
}
Production Trap:
Don't send stack traces in production. Strip them in the error middleware unless NODE_ENV is 'development'. Leaking stack traces is a security vulnerability.
Key Takeaway
Standardize your API envelope (status, data, error, correlationId) in one global handler — never trust controllers to format responses consistently.
Using Async-Await — The Unseen Error Paths You're Ignoring
Async-await didn't eliminate error handling; it just shifted where errors hide. When you wrap an async function in a synchronous try/catch, unhandled promise rejections inside that function still bypass your handler. The root cause: async functions return promises, and if you await a rejected promise inside a try block, the catch fires. But if the rejection happens before the await — like in a synchronous exception thrown inside the async function — it becomes an unhandled rejection unless you catch it at the function entry point. Node.js treats these differently: synchronous errors inside async functions propagate to the returned promise's rejection handler, but only if you actually await or chain .catch(). Missing either corrupts your error flow silently. The fix: wrap every async route handler with a higher-order function that catches both sync and async errors. This ensures a thrown Error inside any async function reaches your global error handler, not the abyss of unhandled rejections that Node.js will crash on in future versions.
routeWrapper.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// io.thecodeforge — javascript tutorialconst asyncHandler = (fn) => (req, res, next) => {
// Catches synchronous throws + async rejectionsPromise.resolve(fn(req, res, next)).catch(next);
};
app.get('/data', asyncHandler(async (req, res) => {
const result = await riskyDbCall(); // rejects here// Even throw new Error('sync') inside here is caught
res.json(result);
}));
// If riskyDbCall throws synchronously, it's still caught// by Promise.resolve(). If it rejects async, .catch(next) triggers global handler.
Output
All errors (sync/async) route to express global error middleware
Production Trap:
asyncHandler doesn't protect against rejections that occur outside the awaited path — like unhandled promise rejections from timers or event emitters spawned inside the async function. Those still corrupt state.
Key Takeaway
Async functions require explicit catch wrapping at the route entry point; nowhere else guarantees both sync and async errors reach your handler.
Quick Checklist — 5 Error Handling Gates Before Production
Most Node.js failures in production trace back to 5 missing gates. First, gate one: every async route handler must be wrapped by a catch-all function. Second, gate two: a global error middleware (4-parameter function) must be registered after all routes. Third, gate three: process.on('unhandledRejection') must log and exit to restart cleanly — never swallow it. Fourth, gate four: all database and external API calls need per-call timeout middleware; hanging promises crash nothing but lock resources. Fifth, gate five: every thrown error must include a unique correlation ID (UUID) for tracing across microservices. Run this checklist: (1) Can you import a route and cause a synchronous throw inside an async function? It must propagate. (2) Does your Express server log the stack trace before sending a 500? (3) When a Redis connection fails mid-request, does the request timeout or return an appropriate 503? (4) Are all custom errors instances of Error? (5) Do you catch errors in Promise.all() — a single rejection rejects the whole group, leaving other promises orphaned. Address these five gates and you've eliminated 90% of silent failures.
All 5 gates active: routes wrapped, global handler, process exit, timeouts, correlation IDs
Production Trap:
Gate 3's process.exit() is brutal but correct — Node.js deprecates unhandled rejections in future versions, and silent corruption is worse than a restart.
Key Takeaway
Run these 5 gates as a hard prerequisite before any production deployment; missing one creates a silent failure vector.
Explanation — Why Errors Bleed Through the Cracks
Most Node.js developers learn error handling by patching surface symptoms—a try/catch here, an .catch() there—without understanding the four fundamental error delivery mechanisms: synchronous throw, callbacks with err, event emitters emitting 'error', and rejected promises. When you mix these paradigms, errors can vanish silently. A thrown Error inside a setTimeout fires no listener. A promise rejection in an async function that returns a promise? Caught beautifully—but only if you await or chain .catch(). If you fire off an async IIFE without handling its returned promise, that rejection becomes an unhandledPromiseRejection—terminating the process in future Node versions. The silent failures happen precisely where developers assume safety: inside event listeners, streams, or microtask queues. Understanding that every error must eventually hit a handler of its idiom means you stop chasing bugs and start architecting predictable failure paths.
If any code path mixes callback APIs with promises without promisification, the error is neither caught by .catch() nor by the callback listener—it becomes a ghost error.
Key Takeaway
Map every async idiom to its error delivery mechanism; never assume an error will be caught by a handler from a different paradigm.
Using Async-Await — The Unseen Error Paths You're Ignoring
Async-await is syntactic sugar over promises, but it introduces a subtle pitfall: errors thrown inside an async function become rejected promises—but only if the function is awaited. Consider an async middleware function in Express: if you call it without await inside a route handler, any rejection becomes an unhandled promise rejection. Worse, when using forEach with an async callback, the forEach method does not await the returned promises—so errors in any iteration are silently swallowed. The fix is simple: use for...of or Promise.allSettled when you need error awareness. Another hidden path: if an async function calls another async function without await, the caller's try/catch sees nothing. Always treat async function calls as promise chains—if you don't await, you must .catch() or the error vanishes into Node's unhandled rejection abyss, crashing your service in Node 15+.
An unhandled promise rejection from an unawaited async function terminates the Node process in v15+. Use process.on('unhandledRejection') to log and crash gracefully.
Key Takeaway
Every async function returns a promise; if you don't await or .catch(), the error disappears into unhandled rejection territory.
● 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.
Key takeaways
1
Node.js has four error channels
throw, callback, Promise, EventEmitter — missing any one means silent failure.
2
Custom error classes with statusCode, errorCode, and isOperational enable precise error handling and prevent crash loops.
3
Express async routes need asyncHandler wrapper in 4.x
without it, errors become unhandled rejections that crash the process.
4
Use Promise.allSettled for independent operations; Promise.all for true all-or-nothing flows.
5
The isOperational flag is the architectural line between expected failures and unknown bugs.
Common mistakes to avoid
5 patterns
×
Using a generic Error for everything — no type, no status code, no error code
Symptom
Global handler always returns 500 even for validation errors (422) or not-found (404). Monitoring cannot distinguish client errors from server errors. Developers waste time parsing error.message strings.
Fix
Create custom error classes that extend Error and include statusCode, errorCode, isOperational, and domain-specific properties. Use instanceof in the global handler to route to the correct HTTP response.
×
Calling an async function inside try/catch without await — the error becomes an unhandled rejection
Symptom
The catch block never fires. In Node.js 22, the process exits with an unhandled rejection. The error is invisible in the route handler's context. Under load, data is silently lost.
Fix
Always use await when calling an async function. Enable ESLint rule @typescript-eslint/no-floating-promises to catch this at CI time. If you don't need the result, assign to void: void someAsyncFunction() or add .catch().
×
Not attaching an 'error' event listener on EventEmitters (streams, servers, sockets)
Symptom
The process crashes with an uncaught exception when the emitter fires an 'error' event. The stack trace points to Node.js internals, not your code. Debugging is slow because the error origin is unclear.
Fix
Always attach .on('error', handler) before calling any methods on an EventEmitter. In streams, you can also use pipeline() which handles error propagation automatically. Add a default error handler in a base class if you create multiple emitters.
×
Using Promise.all() for independent operations — one failure discards all partial results
Symptom
When one of several parallel operations fails (e.g., fetching data from multiple APIs), the entire Promise.all rejects and you lose all successful responses. Dashboards show incomplete data or batch jobs fail halfway.
Fix
Use Promise.allSettled() when operations are independent and partial results are still useful. Iterate the results array and handle each {status: 'fulfilled', value} or {status: 'rejected', reason}. Reserve Promise.all() for true all-or-nothing transactions.
×
Failing to add the isOperational flag — crashing on expected errors like 404s
Symptom
The process-level uncaughtException handler restarts the process on every known error (404, validation failure). Availability suffers, logs are noisy, and on-call engineers get paged for normal behavior.
Fix
Add an isOperational boolean to your base error class. Set it to true for expected errors (validation, not-found, rate-limit, external timeout). Set it to false for unexpected bugs. In the crash handler, only restart on non-operational errors. Log operational errors normally.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01JUNIOR
What are the four error delivery channels in Node.js?
Q02SENIOR
Why does Express not catch errors from async route handlers automaticall...
Q03SENIOR
How would you design error handling for a payment processing microservic...
Q04SENIOR
What is the danger of using Promise.all() for operations that are not tr...
Q05SENIOR
How does Node.js 22 handle unhandled Promise rejections?
Q01 of 05JUNIOR
What are the four error delivery channels in Node.js?
ANSWER
Synchronous throws (try/catch), error-first callbacks (first argument is null or Error), Promise rejections (.catch() or try/catch with async/await), and EventEmitter 'error' events (attach .on('error') listener). Missing any channel allows silent failures.
Q02 of 05SENIOR
Why does Express not catch errors from async route handlers automatically?
ANSWER
Express 4.x expects route handlers to call next(error) synchronously. Async functions return a Promise; if it rejects, Express does not know about it because the handler already returned. The rejection becomes an unhandled Promise rejection. The fix is the asyncHandler wrapper that catches rejections and passes them to next(error). Express 5 fixes this natively.
Q03 of 05SENIOR
How would you design error handling for a payment processing microservice?
ANSWER
Use custom error classes with statusCode, errorCode, and isOperational. Distinguish between validation (422), resource not found (404), external service failure (502), and internal bugs (500). Use asyncHandler for Express routes. Log all errors with request context. For critical paths like webhooks, add post-write verification (write then read back). Use Promise.allSettled for independent operations. Add process-level handlers for uncaught exceptions and unhandled rejections, but only crash on non-operational errors. Use diagnostics_channel for cross-library observability.
Q04 of 05SENIOR
What is the danger of using Promise.all() for operations that are not truly all-or-nothing?
ANSWER
If one operation fails, Promise.all() rejects immediately and discards all other results — even if they succeeded. This leads to data loss in batch jobs or incomplete aggregations. Instead, use Promise.allSettled() to get an array of {status, value/reason} for each operation. Iterate and handle partial successes separately.
Q05 of 05SENIOR
How does Node.js 22 handle unhandled Promise rejections?
ANSWER
By default, Node.js 22 (and since Node.js 15) terminates the process with exit code 1 on an unhandled rejection. There is no warning-only mode unless you explicitly pass --unhandled-rejections=warn. This is intentional because silent failures are worse than crashes. The fix is to always add .catch() or use try/catch with async/await. Use process.on('unhandledRejection') as a safety net to log and exit gracefully.
01
What are the four error delivery channels in Node.js?
JUNIOR
02
Why does Express not catch errors from async route handlers automatically?
SENIOR
03
How would you design error handling for a payment processing microservice?
SENIOR
04
What is the danger of using Promise.all() for operations that are not truly all-or-nothing?
SENIOR
05
How does Node.js 22 handle unhandled Promise rejections?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
What happens if I don't add an 'error' listener to a stream?
If the stream emits an 'error' event and there is no listener, Node.js throws an uncaught exception and the process crashes. This is true for all EventEmitters in Node.js. Always attach an error listener before calling any methods that could trigger an event.
Was this helpful?
02
Should I use try/catch or .catch() for Promise errors?
Both work, but prefer try/catch inside async functions for consistent stack traces and easier flow control. Use .catch() when you need to handle the error without blocking the rest of the function (e.g., logging and rethrowing). Never leave a Promise without either.
Was this helpful?
03
How do I distinguish between expected errors and bugs in the error handler?
Use an isOperational flag on your custom error classes. Set it to true for errors you anticipate (validation, not-found, external service timeouts). Only restart the process on non-operational errors (bugs). Log operational errors normally.
Was this helpful?
04
What is diagnostics_channel in Node.js 22?
diagnostics_channel is a stable API for subscribing to structured error events across the Node.js ecosystem without modifying third-party code. You can create a named channel and publish error events to it. Your observability layer subscribes once and receives all errors from any library that publishes to the channel.
Was this helpful?
05
Does Express 5 handle async route errors automatically?
Yes, Express 5 natively catches rejections from async route handlers and passes them to the error middleware. The asyncHandler wrapper is not needed. However, Express 4.x is still widely used, so check your dependency version.