Introduction
Error handling is the difference between a Node.js application that runs in development and one that survives production. The asynchronous, event-driven nature of Node.js makes error handling uniquely challenging compared to synchronous languages like Python or Java. A single unhandled error can crash your entire process, taking down every connected client with it.
Node.js applications face several categories of errors: operational errors like network timeouts and file-not-found conditions, programmer errors like null reference exceptions and type errors, and system errors like out-of-memory conditions. Each category requires a different handling strategy, and conflating them is the most common mistake developers make.
This guide covers every error handling pattern in Node.js, from the callback era through Promises and async/await, including Express middleware patterns, process-level error handling, and production monitoring strategies. By the end, you will have a comprehensive error handling architecture that catches errors at every layer of your application.
Understanding Error Types: Core Concepts
Operational Errors vs Programmer Errors
The most important distinction in error handling is between operational errors and programmer errors. Operational errors represent expected problems that your code must handle: a database query fails, a file does not exist, a network connection times out, or a user sends invalid input. These are not bugs. They are normal conditions that your application must anticipate and respond to gracefully.
Programmer errors are bugs. They represent mistakes in your code: reading a property of undefined, passing the wrong type to a function, or calling a function that does not exist. These should never happen in production, and the correct response is to crash and restart.
// Operational error: expected, handle gracefully
async function getUser(id: string) {
try {
const user = await db.users.findById(id);
if (!user) {
throw new AppError('USER_NOT_FOUND', `User ${id} not found`);
}
return user;
} catch (err) {
if (err instanceof AppError) {
// Operational: return meaningful error to client
throw err;
}
// Unexpected: database connection failed, etc.
logger.error('Database error in getUser', { id, error: err });
throw new AppError('INTERNAL_ERROR', 'Something went wrong');
}
}
// Programmer error: should crash the process
function divide(a: number, b: number): number {
if (b === 0) {
// This should never happen if callers are correct
throw new RangeError('Division by zero');
}
return a / b;
}The Error Object
JavaScript Error objects contain a message, a stack trace, and optionally a code property. In Node.js, system errors also include an errno property and a syscall property that identify the specific system call that failed.
class AppError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly statusCode: number = 500,
public readonly isOperational: boolean = true
) {
super(message);
this.name = 'AppError';
Error.captureStackTrace(this, this.constructor);
}
}
// Usage
throw new AppError('VALIDATION_ERROR', 'Email is required', 400);
throw new AppError('NOT_FOUND', 'User not found', 404);
throw new AppError('RATE_LIMITED', 'Too many requests', 429);The Error-First Callback Pattern
Node.js adopted the error-first callback pattern as its standard asynchronous error handling mechanism. The first argument to every callback is an error (or null if successful), and subsequent arguments are the results.
import fs from 'fs';
// Error-first callback
fs.readFile('/path/to/file', 'utf-8', (err, data) => {
if (err) {
// Handle the error
if (err.code === 'ENOENT') {
console.error('File not found');
} else {
console.error('Read error:', err.message);
}
return;
}
console.log(data);
});This pattern works but has a well-known problem: nested callbacks create callback hell, making code difficult to read and maintain. Error handling becomes scattered across multiple nested levels.
Architecture and Design Patterns
Promise-Based Error Handling
Promises improved error handling by providing a chainable API with a single catch point at the end of the chain.
import { readFile } from 'fs/promises';
// Promise chain
readFile('/path/to/file', 'utf-8')
.then(data => JSON.parse(data))
.then(config => initializeApp(config))
.catch(err => {
if (err.code === 'ENOENT') {
console.error('Config file not found, using defaults');
return initializeApp(defaultConfig);
}
console.error('Fatal error:', err);
process.exit(1);
});The key insight with Promises is that errors propagate automatically through the chain until they hit a .catch(). Any error thrown in a .then() handler is caught by the next .catch() in the chain. This eliminates the scattered error handling of callbacks.
However, Promises have a critical pitfall: forgetting .catch() results in an unhandled promise rejection that may crash your process in newer Node.js versions.
Async/Await Error Handling
Async/await is syntactic sugar over Promises that makes asynchronous code look synchronous. Error handling uses familiar try/catch blocks.
import { readFile } from 'fs/promises';
async function loadConfig(): Promise<Config> {
try {
const data = await readFile('/path/to/config.json', 'utf-8');
return JSON.parse(data);
} catch (err) {
if (err.code === 'ENOENT') {
console.warn('Config file not found, using defaults');
return defaultConfig;
}
throw err; // Re-throw unexpected errors
}
}The async/await pattern has a subtle but critical issue: if you forget the try/catch block, the error becomes an unhandled promise rejection. This is why many developers wrap async functions in a utility.
// Utility to wrap async functions for Express
type AsyncHandler = (
req: Request,
res: Response,
next: NextFunction
) => Promise<void>;
function asyncHandler(fn: AsyncHandler) {
return (req: Request, res: Response, next: NextFunction) => {
fn(req, res, next).catch(next);
};
}
// Usage in Express
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await getUser(req.params.id);
res.json(user);
}));Error Hierarchy
A well-structured error hierarchy makes error handling consistent across your application.
// Base application error
class AppError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly statusCode: number = 500,
public readonly isOperational: boolean = true
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
// Specific error types
class ValidationError extends AppError {
constructor(message: string, public readonly fields?: Record<string, string>) {
super('VALIDATION_ERROR', message, 400);
}
}
class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super('NOT_FOUND', `${resource} with id ${id} not found`, 404);
}
}
class AuthenticationError extends AppError {
constructor(message: string = 'Authentication required') {
super('UNAUTHORIZED', message, 401);
}
}
class AuthorizationError extends AppError {
constructor(message: string = 'Insufficient permissions') {
super('FORBIDDEN', message, 403);
}
}
class ConflictError extends AppError {
constructor(message: string) {
super('CONFLICT', message, 409);
}
}
class RateLimitError extends AppError {
constructor(retryAfter: number) {
super('RATE_LIMITED', `Too many requests. Retry after ${retryAfter}s`, 429);
}
}Step-by-Step Implementation
Express Error Handling Middleware
Express has a built-in error handling mechanism through error middleware functions. These functions have four parameters: err, req, res, and next.
import express, { Request, Response, NextFunction } from 'express';
const app = express();
// Regular routes
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await getUser(req.params.id);
res.json(user);
}));
app.post('/users', asyncHandler(async (req, res) => {
const { email, name } = req.body;
if (!email) {
throw new ValidationError('Email is required', { email: 'required' });
}
const user = await createUser({ email, name });
res.status(201).json(user);
}));
// Error handling middleware (must be after all routes)
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
// Log the error
console.error(`[${new Date().toISOString()}] ${req.method} ${req.url}:`, {
error: err.message,
stack: err.stack,
requestId: req.headers['x-request-id'],
});
// Handle known operational errors
if (err instanceof AppError && err.isOperational) {
return res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
...(err instanceof ValidationError && { fields: err.fields }),
},
});
}
// Unknown errors: don't leak internal details
res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
},
});
});Database Error Handling
Database operations are a common source of operational errors. Each database driver has its own error types that you need to handle.
import { Prisma } from '@prisma/client';
async function createUser(data: CreateUserInput) {
try {
return await prisma.user.create({ data });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
switch (err.code) {
case 'P2002':
throw new ConflictError(
`User with this ${err.meta?.target} already exists`
);
case 'P2003':
throw new ValidationError('Referenced record not found');
case 'P2025':
throw new NotFoundError('Record', 'specified');
default:
throw new AppError('DATABASE_ERROR', err.message, 500);
}
}
if (err instanceof Prisma.PrismaClientValidationError) {
throw new ValidationError('Invalid data provided');
}
throw err; // Re-throw unexpected errors
}
}External API Error Handling
When calling external APIs, you must handle network errors, timeouts, and unexpected response formats.
import axios, { AxiosError } from 'axios';
class ExternalServiceError extends AppError {
constructor(
service: string,
message: string,
public readonly originalError?: Error
) {
super('EXTERNAL_SERVICE_ERROR', `${service}: ${message}`, 502);
}
}
async function callPaymentService(order: Order) {
try {
const response = await axios.post('https://payments.example.com/charge', {
amount: order.total,
currency: order.currency,
}, {
timeout: 10000,
headers: { 'X-Idempotency-Key': order.id },
});
return response.data;
} catch (err) {
if (err instanceof AxiosError) {
if (err.code === 'ECONNABORTED') {
throw new ExternalServiceError('PaymentService', 'Request timeout');
}
if (err.response) {
throw new ExternalServiceError(
'PaymentService',
`HTTP ${err.response.status}: ${err.response.data?.message}`
);
}
if (err.request) {
throw new ExternalServiceError('PaymentService', 'No response received');
}
}
throw new ExternalServiceError('PaymentService', 'Unknown error', err as Error);
}
}File System Error Handling
import fs from 'fs/promises';
async function readConfigFile(path: string): Promise<Config> {
try {
const data = await fs.readFile(path, 'utf-8');
return JSON.parse(data);
} catch (err: any) {
switch (err.code) {
case 'ENOENT':
throw new AppError('CONFIG_NOT_FOUND', `Config file ${path} not found`, 500);
case 'EACCES':
throw new AppError('CONFIG_ACCESS_DENIED', `Cannot read ${path}`, 500);
case 'EISDIR':
throw new AppError('CONFIG_IS_DIRECTORY', `${path} is a directory`, 500);
default:
if (err instanceof SyntaxError) {
throw new AppError('CONFIG_INVALID_JSON', `Invalid JSON in ${path}`, 500);
}
throw err;
}
}
}Real-World Use Cases
Use Case 1: API Error Response Standardization
A REST API needs consistent error responses across all endpoints. This middleware catches all errors and formats them uniformly.
// Standard error response format
interface ErrorResponse {
error: {
code: string;
message: string;
details?: Record<string, any>;
requestId?: string;
};
}
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
const requestId = req.headers['x-request-id'] as string;
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
...(err instanceof ValidationError && { details: err.fields }),
requestId,
},
});
}
// Log unexpected errors with full context
logger.error({
message: err.message,
stack: err.stack,
requestId,
method: req.method,
url: req.url,
body: req.body,
});
res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
requestId,
},
});
});Use Case 2: Graceful Shutdown
When your process receives a termination signal, you need to close all connections and finish in-flight requests before exiting.
import http from 'http';
const server = http.createServer(app);
async function gracefulShutdown(signal: string) {
console.log(`Received ${signal}. Starting graceful shutdown...`);
// Stop accepting new connections
server.close(() => {
console.log('HTTP server closed');
});
// Close database connections
await prisma.$disconnect();
console.log('Database disconnected');
// Close Redis connections
await redis.quit();
console.log('Redis disconnected');
// Close message queue connections
await mq.close();
console.log('Message queue closed');
process.exit(0);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
// Set a timeout for graceful shutdown
setTimeout(() => {
console.error('Graceful shutdown timed out. Forcing exit.');
process.exit(1);
}, 30000);Use Case 3: Uncaught Exception Handling
Process-level error handlers catch errors that escape all other handling. These are last-resort safety nets, not error handling strategies.
// Unhandled promise rejections
process.on('unhandledRejection', (reason: unknown, promise: Promise<unknown>) => {
logger.fatal({
message: 'Unhandled promise rejection',
reason: reason instanceof Error ? reason.stack : reason,
});
// In production, crash and let the process manager restart
process.exit(1);
});
// Uncaught synchronous exceptions
process.on('uncaughtException', (error: Error) => {
logger.fatal({
message: 'Uncaught exception',
error: error.message,
stack: error.stack,
});
// Always crash on uncaught exceptions
process.exit(1);
});Best Practices for Production
-
Distinguish operational from programmer errors: Operational errors should be handled and returned to the client. Programmer errors should crash the process so the bug is detected and fixed.
-
Use a custom error class hierarchy: Create specific error classes for each category of error in your application. This makes error handling in middleware clean and consistent.
-
Never swallow errors silently: Always log errors, even if you handle them gracefully. Silent failures make debugging impossible.
-
Use asyncHandler for Express routes: Wrapping async route handlers prevents unhandled promise rejections from crashing your process.
-
Implement centralized error logging: Use a structured logger like Pino or Winston that outputs JSON logs suitable for log aggregation tools like ELK or Datadog.
-
Add request context to error logs: Include the request ID, user ID, URL, method, and body in error logs for debugging context.
-
Set up process-level error handlers: Always listen for
uncaughtExceptionandunhandledRejectionevents as a last resort safety net. -
Implement graceful shutdown: Close all connections and finish in-flight requests before exiting when receiving SIGTERM or SIGINT.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Forgetting asyncHandler in Express | Unhandled promise rejection crashes process | Wrap all async routes with asyncHandler |
| Swallowing errors with empty catch | Silent failures, impossible debugging | Always log or re-throw caught errors |
| Leaking internal error details to clients | Security vulnerability | Return generic messages for 500 errors |
| Not handling Promise.all rejections | One failed promise hides other results | Use Promise.allSettled or individual try/catch |
| Throwing strings instead of Error objects | Missing stack traces and error metadata | Always throw Error instances |
| Catching errors too broadly | Hiding bugs in try/catch blocks | Catch specific error types, re-throw unexpected ones |
| Not setting up process error handlers | Silent crashes with no logs | Add uncaughtException and unhandledRejection handlers |
Performance Optimization
Structured Logging
Use a fast structured logger like Pino for production error logging. JSON output is machine-readable and works well with log aggregation tools.
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
formatters: {
level: (label) => ({ level: label }),
},
serializers: {
err: pino.stdSerializers.err,
req: pino.stdSerializers.req,
res: pino.stdSerializers.res,
},
});
// In error middleware
logger.error({
err,
req: {
method: req.method,
url: req.url,
requestId: req.headers['x-request-id'],
},
userId: req.user?.id,
}, 'Request failed');Error Monitoring with Sentry
Integrate error monitoring to catch and track production errors automatically.
import * as Sentry from '@sentry/node';
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: 0.1,
});
// Capture errors in Express middleware
app.use(Sentry.Handlers.errorHandler());
// Or manually
try {
await riskyOperation();
} catch (err) {
Sentry.captureException(err, {
tags: { feature: 'payment' },
user: { id: user.id, email: user.email },
});
throw err;
}Testing Strategies
Test your error handling code to ensure errors are caught and formatted correctly.
import request from 'supertest';
import { app } from '../app';
describe('Error handling', () => {
it('returns 404 for missing user', async () => {
const res = await request(app)
.get('/users/nonexistent')
.expect(404);
expect(res.body.error.code).toBe('NOT_FOUND');
expect(res.body.error.message).toContain('nonexistent');
});
it('returns 400 for validation errors', async () => {
const res = await request(app)
.post('/users')
.send({ name: 'Test' }) // Missing email
.expect(400);
expect(res.body.error.code).toBe('VALIDATION_ERROR');
expect(res.body.error.fields.email).toBe('required');
});
it('returns 500 with generic message for unexpected errors', async () => {
// Mock a database failure
jest.spyOn(prisma.user, 'findUnique').mockRejectedValue(
new Error('Connection lost')
);
const res = await request(app)
.get('/users/1')
.expect(500);
expect(res.body.error.code).toBe('INTERNAL_ERROR');
expect(res.body.error.message).not.toContain('Connection lost');
});
});Future Outlook
Node.js error handling continues to evolve. The AbortController API provides a standardized way to cancel asynchronous operations, which is important for handling timeouts in fetch requests and other async patterns. The Error.cause property (available since Node.js 16) allows chaining errors to preserve the original error context.
Deno and Bun runtimes are adopting stricter default error handling, with Deno crashing on unhandled rejections by default. This reflects the industry consensus that unhandled errors should be loud, not silent.
Conclusion
Error handling in Node.js requires a multi-layered approach: custom error classes for classification, async/await with try/catch for individual operations, Express middleware for centralized handling, and process-level handlers as a safety net.
Key takeaways:
- Distinguish operational errors from programmer errors
- Use a custom error class hierarchy with HTTP status codes
- Wrap async Express routes with asyncHandler to prevent unhandled rejections
- Implement centralized error logging with structured JSON output
- Never leak internal error details to clients in production
- Set up process-level error handlers as a last resort safety net
- Implement graceful shutdown to close connections cleanly
- Test your error handling code as thoroughly as your business logic
Build your error handling architecture from the bottom up: process handlers catch everything that escapes, Express middleware catches everything that escapes route handlers, and route handlers catch errors from individual operations. This layered approach ensures no error goes unnoticed and every error is handled appropriately.