Senior 13 min · June 04, 2026

Unawaited Promises Cause 40% Data Loss in Node.js

40% of payment webhooks lost data: an unawaited async write returned 200 but never persisted.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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.
Node.js Error Handling Flow for Data Integrity THECODEFORGE.IO Node.js Error Handling Flow for Data Integrity From unhandled rejections to graceful shutdown with correlation IDs Unhandled Promise Rejections Cause data corruption and silent failures Custom Error Classes Replace plain strings with typed errors Error Wrapping Preserve cause chain for debugging Express Global Error Middleware Single handler catches all route errors Error Correlation IDs Trace requests across microservices Graceful Shutdown SIGTERM drains requests before exit ⚠ Missing async wrapper leads to uncaught rejections Always wrap Express route handlers with async error catcher THECODEFORGE.IO
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 22
import { 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
// ─────────────────────────────────────────────────────────────────
function parseWebhookPayload(rawBody) {
  // JSON.parse throws synchronously on malformed input
  // Under high load, this is a common event loop blocker if payloads are large
  return JSON.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
// ─────────────────────────────────────────────────────────────────
async function fetchUserFromDatabase(userId) {
  // Simulates a database call that rejects — e.g. connection timeout
  if (!userId || typeof userId !== 'number') {
    return Promise.reject(new TypeError(`fetchUserFromDatabase expects a number, got ${typeof userId}`));
  }
  return Promise.reject(new Error(`User ${userId} not found in users table`));
}

async function loadUserProfile(userId) {
  try {
    // Without this try/catch, the rejection becomes UnhandledPromiseRejection
    // In Node.js 22, that exits the process with code 1
    const user = await fetchUserFromDatabase(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 required
if (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:
function publishError(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.
IfObject extends EventEmitter — streams, net.Socket, http.Server, database connection pools
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
// 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
// ─────────────────────────────────────────────────────────────────
abstract class AppError extends Error {
  public readonly statusCode: number;
  public readonly errorCode: string;
  public readonly isOperational: boolean;

  constructor(message: string, statusCode: number, errorCode: string) {
    super(message);
    this.name = this.constructor.name; // 'ValidationError', 'DatabaseError', etc.
    this.statusCode = statusCode;
    this.errorCode = errorCode;
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

// Specific error types
class ValidationError extends AppError {
  public readonly fieldName: string;
  public readonly receivedValue?: unknown;

  constructor(message: string, fieldName: string, receivedValue?: unknown) {
    super(message, 422, 'VALIDATION_ERROR');
    this.fieldName = fieldName;
    this.receivedValue = receivedValue; // Log server-side, never expose to client
  }
}

class DatabaseError extends AppError {
  public readonly queryContext?: Record<string, unknown>;
  public readonly isRetryable: boolean;

  constructor(message: string, queryContext?: Record<string, unknown>, isRetryable = true) {
    super(message, 503, 'DATABASE_UNAVAILABLE');
    this.queryContext = queryContext;
    this.isRetryable = isRetryable;
  }
}

class NotFoundError extends AppError {
  public readonly resourceType: string;
  public readonly resourceId: string | number;

  constructor(resourceType: string, resourceId: string | number) {
    super(`${resourceType} with ID ${resourceId} was not found`, 404, 'NOT_FOUND');
    this.resourceType = resourceType;
    this.resourceId = resourceId;
  }
}

class RateLimitError extends AppError {
  public readonly retryAfterSeconds: number;

  constructor(retryAfterSeconds: number) {
    super(`Rate limit exceeded. Retry after ${retryAfterSeconds} seconds.`, 429, 'RATE_LIMIT_EXCEEDED');
    this.retryAfterSeconds = retryAfterSeconds;
  }
}

class ExternalServiceError extends AppError {
  public readonly serviceName: string;
  public readonly upstreamStatus?: number;

  constructor(serviceName: string, message: string, upstreamStatus?: number) {
    super(message, 502, 'EXTERNAL_SERVICE_ERROR');
    this.serviceName = serviceName;
    this.upstreamStatus = upstreamStatus;
  }
}

// Usage simulation
async function createUserAccount(userData: { email?: string; password?: string; username?: string }): Promise<{ id: number; email: string }> {
  if (!userData.email?.includes('@')) {
    throw new ValidationError('A valid email address is required', 'email', userData.email);
  }
  if (!userData.password || userData.password.length < 12) {
    throw new ValidationError('Password must be at least 12 characters', 'password');
  }
  const isDatabaseReachable = false;
  if (!isDatabaseReachable) {
    throw new DatabaseError('Primary database is unreachable', { operation: 'INSERT', table: 'users' }, true);
  }
  return { id: 1001, email: userData.email };
}

async function handleCreateUser(requestBody: Record<string, unknown>) {
  try {
    const user = await createUserAccount(requestBody as any);
    console.log('User created:', user.id);
  } catch (error) {
    if (error instanceof ValidationError) {
      console.log(`[422] Field: ${error.fieldName}, Message: ${error.message}`);
    } else if (error instanceof DatabaseError) {
      console.log(`[503] ${error.message}`);
    } else if (error instanceof ExternalServiceError) {
      console.log(`[502] Service ${error.serviceName}: ${error.message}`);
    } else if (error instanceof AppError) {
      console.log(`[${error.statusCode}] ${error.errorCode}: ${error.message}`);
    } else {
      console.error('[500] Unexpected bug:', error);
    }
  }
}

// Tests
await handleCreateUser({ email: 'not-email', password: 'short' });
await handleCreateUser({ email: 'user@example.com', password: 'securepassword123!!' });
Output
[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.

io/thecodeforge/errors/expressErrorHandling.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
import express, { Request, Response, NextFunction, ErrorRequestHandler } from 'express';
import { randomUUID } from 'crypto';

const app = express();
app.use(express.json({ limit: '1mb' }));

// Custom error classes (imported from customErrors.ts)
class AppError extends Error {
  constructor(public statusCode: number, public errorCode: string, message: string, public isOperational = true) {
    super(message);
    this.name = this.constructor.name;
    Error.captureStackTrace(this, this.constructor);
  }
}

class NotFoundError extends AppError {
  constructor(resourceType: string, resourceId: string | number) {
    super(404, 'NOT_FOUND', `${resourceType} with id '${resourceId}' does not exist`);
  }
}

class ValidationError extends AppError {
  constructor(message: string, public readonly fieldName: string) {
    super(422, 'VALIDATION_ERROR', message);
  }
}

// asyncHandler wrapper — required for Express 4.x
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);
  };
};

// Route using asyncHandler
app.get('/users/:id', asyncHandler(async (req, res) => {
  const userId = parseInt(req.params.id, 10);
  if (isNaN(userId)) {
    throw new ValidationError('Invalid user ID', 'id');
  }
  // Simulate database lookup
  if (userId !== 1) {
    throw new NotFoundError('User', userId);
  }
  res.json({ id: 1, name: 'Alice' });
}));

// Global error middleware — exactly 4 arguments
const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
  // Log every error with request context
  console.error(`[${new Date().toISOString()}] Error:`, {
    errorCode: err.errorCode || 'UNKNOWN',
    message: err.message,
    stack: err.stack,
    requestId: req.headers['x-request-id'] || randomUUID(),
    path: req.path,
    method: req.method,
  });

  // Determine response status and body
  const statusCode = err instanceof AppError ? err.statusCode : 500;
  const errorCode = err instanceof AppError ? err.errorCode : 'INTERNAL_ERROR';
  const message = err instanceof AppError ? err.message : 'An unexpected error occurred';

  res.status(statusCode).json({
    error: { code: errorCode, message },
  });
};

app.use(errorHandler);

app.listen(3000, () => console.log('Server running on port 3000'));
Output
Server running on port 3000
GET /users/abc → [ValidationError] → 422
GET /users/999 → [NotFoundError] → 404
GET /users/1 → 200
Express 5 vs Express 4: Async Route Handling
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.

CorrelationTracer.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — javascript tutorial

import { AsyncLocalStorage } from 'async_hooks';
import crypto from 'crypto';

const asyncStore = new AsyncLocalStorage();

export function withCorrelation(req, next) {
  const correlationId = req.headers['x-correlation-id'] || crypto.randomUUID();
  const store = { correlationId };
  asyncStore.run(store, () => next());
}

export function getCorrelationId() {
  return asyncStore.getStore()?.correlationId ?? 'unset';
}

// Usage in error handler
function handleError(err) {
  const correlationId = getCorrelationId();
  console.error(`[${correlationId}] ${err.message}`);
  // Output: [a1b2c3d4] Payment gateway timeout
}
Output
[a1b2c3d4] Payment gateway timeout
Production Trap:
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 tutorial

class DatabaseError extends Error {
  constructor(message, cause) {
    super(message, { cause });
    this.name = 'DatabaseError';
  }
}

function printCauseChain(err) {
  let current = err;
  while (current) {
    console.error(`[${current.name}] ${current.message}`);
    current = current.cause;
  }
}

// Production usage
const original = new Error('connect ECONNREFUSED 10.0.0.1:5432');
const wrapped = new DatabaseError('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.

GracefulShutdown.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — javascript tutorial

import http from 'http';

const server = http.createServer((req, res) => {
  res.end('ok');
});

server.listen(3000, () => console.log('Listening'));

function shutdown(signal) {
  console.log(`[${signal}] Shutting down...`);
  server.close(() => process.exit(0));
  setTimeout(() => {
    console.error('Forced exit after timeout');
    process.exit(1);
  }, 10000);
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// Output on SIGTERM:
// [SIGTERM] Shutting down...
Output
[SIGTERM] Shutting down...
Production Trap:
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 tutorial

const asyncWrapper = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

router.get('/orders/:id', asyncWrapper(async (req, res) => {
  const order = await Order.findById(req.params.id);
  if (!order) {
    const err = new Error('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 tutorial

const asyncHandler = (fn) => (req, res, next) => {
  // Catches synchronous throws + async rejections
  Promise.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.

errorGates.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
// io.thecodeforge — javascript tutorial

// Gate 1: Wrapped routes
app.use('/', asyncHandler(route));

// Gate 2: Global middleware (last)
app.use((err, req, res, next) => {
  console.error('Gate 2:', err.message);
  res.status(500).json({ error: err.message });
});

// Gate 3: Process-level
process.on('unhandledRejection', (reason) => {
  console.error('Gate 3:', reason);
  process.exit(1); // Force restart
});

// Gate 4: Timeout all external calls
const withTimeout = (promise, ms) => Promise.race([
  promise,
  new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms))
]);

// Gate 5: Correlation IDs on every error
const error = new Error('db fail');
error.correlationId = uuidv4();
Output
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.

errorDeliveryModes.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// io.thecodeforge — javascript tutorial
// Four fundamental error delivery modes
function syncThrow() { throw new Error('sync'); }          // 1. synchronous

const asyncCallback = (cb) => cb(new Error('callback')); // 2. callback err

const emitter = new EventEmitter();
emitter.on('error', (e) => console.log(e.message));  // 3. event emitter
emitter.emit('error', new Error('emitter'));

const promiseReject = Promise.reject(new Error('promise')); // 4. promise
// Without .catch() → unhandled rejection = process.exit

// Mixed idioms = lost errors
setTimeout(() => { throw new Error('lost'); }, 100); // uncaught!
Production Trap:
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+.

asyncAwaitErrors.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — javascript tutorial
// Silent errors in async-await patterns
const risky = async () => { throw new Error('fail'); };

// BAD: forEach swallows rejection
[1,2].forEach(async () => await risky()); // silent

// BAD: unawaited async call in try
async function handler() {
  try { risky(); } catch (e) {} // never catches
}

// GOOD: await or catch
async function safe() {
  try { await risky(); } catch (e) { console.error(e.message); }
}

// BAD: microtask errors in map
const results = [1].map(async () => risky()); // promises, not errors
Production Trap:
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
Commands
grep -rn 'await\|\.catch(' src/ | grep -v node_modules | grep -v test
node --unhandled-rejections=throw app.js
Fix now
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
Commands
grep -rn 'asyncHandler\|catchAsync\|\.catch(next' src/routes/
grep -rn 'async (req' src/routes/ | grep -v asyncHandler
Fix now
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
Commands
grep -rn 'isOperational\|statusCode\|instanceof' src/middleware/error
curl -s http://localhost:3000/api/nonexistent-resource | jq '.error.code'
Fix now
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
Commands
node --version | grep -E 'v(1[5-9]|[2-9][0-9])'
grep -rn 'unhandledRejection\|uncaughtException' src/
Fix now
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.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What happens if I don't add an 'error' listener to a stream?
02
Should I use try/catch or .catch() for Promise errors?
03
How do I distinguish between expected errors and bugs in the error handler?
04
What is diagnostics_channel in Node.js 22?
05
Does Express 5 handle async route errors automatically?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's Node.js. Mark it forged?

13 min read · try the examples if you haven't

Previous
Socket.io and WebSockets
13 / 18 · Node.js
Next
Node.js Performance Optimisation