Node.js Error Handling — The Complete Production Guide 2026
Advertisement
Introduction
Why This Matters
A Node.js process that crashes in production restarts under a process manager — but crashes are disruptive, lose in-flight requests, and mask the actual root cause if error information is not captured before exit. The difference between an app that crashes and one that stays up is almost entirely in how errors are handled.
This guide covers every layer of error handling in a production Node.js + Express application: domain-specific error classes, centralized Express middleware, async wrappers that eliminate repetitive try/catch, process-level safety nets, and graceful shutdown.
Custom Error Classes
Typed error classes make handling predictable — you always know exactly what kind of failure you are dealing with:
// src/errors.ts
export class AppError extends Error {
public readonly statusCode: number
public readonly isOperational: boolean // operational = expected, known error
constructor(message: string, statusCode = 500, isOperational = true) {
super(message)
this.name = this.constructor.name
this.statusCode = statusCode
this.isOperational = isOperational
Error.captureStackTrace(this, this.constructor)
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 404)
}
}
export class ValidationError extends AppError {
public readonly fields: Record<string, string[]>
constructor(message: string, fields: Record<string, string[]> = {}) {
super(message, 400)
this.fields = fields
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401)
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Access denied') {
super(message, 403)
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 409)
}
}Centralized Express Error Middleware
A single 4-argument function handles all errors — place it after all routes:
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express'
import { AppError, ValidationError } from '../errors'
import logger from '../utils/logger'
export function errorHandler(
err: Error,
req: Request,
res: Response,
_next: NextFunction,
) {
// Operational errors — known, expected failures
if (err instanceof ValidationError) {
return res.status(err.statusCode).json({
status: 'fail',
message: err.message,
fields: err.fields,
})
}
if (err instanceof AppError) {
logger.warn({
message: err.message,
statusCode: err.statusCode,
path: req.path,
method: req.method,
})
return res.status(err.statusCode).json({
status: 'fail',
message: err.message,
})
}
// Programming errors — unexpected, do not leak details
logger.error({
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
body: req.body,
})
res.status(500).json({
status: 'error',
message: process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message,
})
}Async Route Handler Wrapper
Eliminate try/catch from every route with a higher-order wrapper:
// src/utils/asyncHandler.ts
import { Request, Response, NextFunction } from 'express'
type AsyncRouteHandler = (
req: Request,
res: Response,
next: NextFunction,
) => Promise<unknown>
export function asyncHandler(fn: AsyncRouteHandler) {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next)
}
}// src/controllers/userController.ts
import { asyncHandler } from '../utils/asyncHandler'
import { NotFoundError, ConflictError } from '../errors'
// No try/catch — errors are caught by asyncHandler and forwarded to errorHandler
export const getUser = asyncHandler(async (req, res) => {
const user = await db.user.findById(req.params.id)
if (!user) throw new NotFoundError('User')
res.json(user)
})
export const createUser = asyncHandler(async (req, res) => {
const existing = await db.user.findByEmail(req.body.email)
if (existing) throw new ConflictError('Email already registered')
const user = await db.user.create(req.body)
res.status(201).json(user)
})
export const deleteUser = asyncHandler(async (req, res) => {
const user = await db.user.findById(req.params.id)
if (!user) throw new NotFoundError('User')
await db.user.delete(req.params.id)
res.status(204).send()
})Handling Unhandled Rejections and Exceptions
Always add process-level safety nets — even with good async handling, third-party code or race conditions can produce unhandled errors:
// src/index.ts
import express from 'express'
import { errorHandler } from './middleware/errorHandler'
const app = express()
app.use(express.json())
app.use('/api/users', userRoutes)
app.use(errorHandler) // Must be registered last
const server = app.listen(3000, () => {
console.log('Server running on port 3000')
})
// Async errors that escaped all try/catch blocks
process.on('unhandledRejection', (reason: Error) => {
console.error('UNHANDLED REJECTION — shutting down gracefully:', reason)
server.close(() => process.exit(1))
})
// Synchronous errors that escaped all try/catch blocks
process.on('uncaughtException', (error: Error) => {
console.error('UNCAUGHT EXCEPTION — shutting down immediately:', error)
// Do not try to recover — the process is in an undefined state
process.exit(1)
})uncaughtException exits immediately because the process may have corrupted memory or open handles in an unknown state. unhandledRejection does a graceful shutdown — it closes the HTTP server first so in-flight requests can complete.
Structured Error Logging with Winston
// src/utils/logger.ts
import winston from 'winston'
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
process.env.NODE_ENV === 'production'
? winston.format.json() // Machine-readable in production
: winston.format.prettyPrint(), // Human-readable in development
),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'logs/combined.log' }),
],
})
export default loggerLog operational errors as warn (expected, client-caused) and programming errors as error (unexpected, needs attention):
// In errorHandler.ts
if (err instanceof AppError) {
logger.warn({ message: err.message, statusCode: err.statusCode, path: req.path })
} else {
logger.error({ message: err.message, stack: err.stack, path: req.path })
}Graceful Shutdown
Stop accepting new requests, finish in-flight ones, then clean up resources:
function gracefulShutdown(signal: string) {
console.log(`Received ${signal} — starting graceful shutdown`)
// Stop accepting new HTTP connections
server.close(async () => {
console.log('HTTP server closed')
try {
await db.disconnect()
console.log('Database disconnected')
process.exit(0)
} catch (err) {
console.error('Error during shutdown:', err)
process.exit(1)
}
})
// Force shutdown if graceful shutdown takes too long
setTimeout(() => {
console.error('Graceful shutdown timed out — forcing exit')
process.exit(1)
}, 10_000)
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM')) // Docker/Kubernetes stop
process.on('SIGINT', () => gracefulShutdown('SIGINT')) // Ctrl+CError Handling for Specific Scenarios
Database errors:
import { Prisma } from '@prisma/client'
import { ConflictError, NotFoundError } from '../errors'
function handlePrismaError(err: unknown) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2002') throw new ConflictError('A record with this value already exists')
if (err.code === 'P2025') throw new NotFoundError('Record')
}
throw err // Re-throw unknown errors
}Validation with Zod:
import { z, ZodError } from 'zod'
import { ValidationError } from '../errors'
const CreateUserSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email'),
age: z.number().min(18, 'Must be 18 or older'),
})
export const createUser = asyncHandler(async (req, res) => {
const parsed = CreateUserSchema.safeParse(req.body)
if (!parsed.success) {
throw new ValidationError('Validation failed', parsed.error.flatten().fieldErrors)
}
const user = await db.user.create({ data: parsed.data })
res.status(201).json(user)
})Common Mistakes
Using 3-argument error handlers in Express. Express only routes to an error handler if it has exactly 4 arguments (err, req, res, next). A 3-argument function that catches errors is just a regular middleware — errors will not reach it.
Throwing errors in synchronous middleware without passing them to next. Synchronous throws in regular middleware bubble up correctly, but async errors do not — always use asyncHandler or call next(err) explicitly.
Logging sensitive data in error objects. Database errors sometimes include query text or parameter values. Log only err.message and err.code, not the full error object.
Continuing after uncaughtException. Do not try to recover — restart the process via a process manager (PM2, systemd, Kubernetes). The process state is undefined after an uncaught synchronous exception.
Best Practices
- Create domain-specific error classes for every known failure type — this makes error handling exhaustive and testable.
- Use
asyncHandlerorexpress-async-errorsto avoid writing try/catch in every route handler. - Distinguish operational errors (
isOperational: true) from programming errors — operational errors are client-facing, programming errors need investigation. - Always log the full stack trace for unexpected errors, and always include the request method and path for context.
- Set up
unhandledRejectionanduncaughtExceptionhandlers as a last resort, but treat their invocation as a critical alert.
Key Takeaways
- Custom error classes with
statusCodeandisOperationalproperties make Express error handling exhaustive and predictable. - A single 4-argument Express error middleware handles all errors centrally — register it after all routes.
asyncHandlerwraps async route functions and forwards any rejected Promise to the next error handler, eliminating per-route try/catch.process.on('unhandledRejection')catches async errors that escaped all try/catch blocks — respond with a graceful server shutdown.process.on('uncaughtException')must exit the process immediately — do not attempt recovery because the process state is undefined.- Graceful shutdown stops accepting new connections, waits for in-flight requests to complete, then closes database connections before exiting.
- Log operational errors as warnings and programming errors as errors to distinguish expected client failures from unexpected system failures.
- Zod
safeParsecombined withValidationErrorprovides structured field-level error responses without try/catch overhead.
Advertisement