- Published on
Node.js Error Handling - The Complete Guide
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Poor error handling is the #1 cause of Node.js application crashes. Unhandled promise rejections, missing try/catch blocks, and vague error messages lead to silent failures and terrible debugging sessions.
This guide covers every error handling pattern you need for production-ready Node.js apps.
- Custom Error Classes
- Express Error Handling Middleware
- Async Error Wrapper
- Handling Unhandled Errors
- Error Logging with Winston
- Graceful Shutdown
- Conclusion
Custom Error Classes
Always create domain-specific error classes — it makes handling predictable:
export class AppError extends Error {
public readonly statusCode: number
public readonly isOperational: boolean
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 {
constructor(message: string) {
super(message, 400)
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401)
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 403)
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 409)
}
}
Express Error Handling Middleware
import { Request, Response, NextFunction } from 'express'
import { AppError } from '../errors'
// Central error handler — must have 4 parameters!
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
) {
// Operational errors (our custom errors) — send to client
if (err instanceof AppError) {
return res.status(err.statusCode).json({
status: 'error',
message: err.message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
})
}
// Programming/unknown errors — don't leak details to client
console.error('UNEXPECTED ERROR:', err)
res.status(500).json({
status: 'error',
message: process.env.NODE_ENV === 'production'
? 'Something went wrong'
: err.message,
})
}
Async Error Wrapper
A common pattern to avoid try/catch in every route:
import { Request, Response, NextFunction } from 'express'
type AsyncRouteHandler = (
req: Request,
res: Response,
next: NextFunction
) => Promise<any>
// Wraps async route handlers to catch errors automatically
export function asyncHandler(fn: AsyncRouteHandler) {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next)
}
}
import { asyncHandler } from '../utils/asyncHandler'
import { NotFoundError } from '../errors'
// No try/catch needed — asyncHandler catches it!
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 user = await db.user.create(req.body)
res.status(201).json(user)
})
Handling Unhandled Errors
Always handle unhandled exceptions and promise rejections:
import express from 'express'
import { errorHandler } from './middleware/errorHandler'
const app = express()
// Routes
app.use('/api/users', userRoutes)
// Error handler (must be LAST)
app.use(errorHandler)
// Handle unhandled promise rejections (async errors outside Express)
process.on('unhandledRejection', (reason: Error) => {
console.error('UNHANDLED REJECTION:', reason)
// Graceful shutdown
server.close(() => process.exit(1))
})
// Handle uncaught synchronous exceptions
process.on('uncaughtException', (error: Error) => {
console.error('UNCAUGHT EXCEPTION:', error)
// Exit immediately — the process may be in an inconsistent state
process.exit(1)
})
const server = app.listen(3000)
Error Logging with Winston
npm install winston
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()
: winston.format.prettyPrint()
),
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 logger
import logger from '../utils/logger'
export function errorHandler(err, req, res, next) {
if (err instanceof AppError) {
logger.warn({
message: err.message,
statusCode: err.statusCode,
path: req.path,
method: req.method,
})
return res.status(err.statusCode).json({ message: err.message })
}
// Unexpected error — log as error
logger.error({
message: err.message,
stack: err.stack,
path: req.path,
})
res.status(500).json({ message: 'Internal server error' })
}
Graceful Shutdown
const server = app.listen(3000)
function gracefulShutdown(signal: string) {
console.log(`Received ${signal}. Starting graceful shutdown...`)
server.close(async () => {
console.log('HTTP server closed')
await db.disconnect() // Close DB connections
console.log('Database disconnected')
process.exit(0)
})
// Force shutdown after 10s if graceful shutdown hangs
setTimeout(() => {
console.error('Forced shutdown after timeout')
process.exit(1)
}, 10_000)
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
process.on('SIGINT', () => gracefulShutdown('SIGINT'))
Conclusion
Robust error handling is what separates toy apps from production software. Use custom error classes for predictable handling, asyncHandler to eliminate boilerplate, centralized Express error middleware, and always handle unhandledRejection and uncaughtException. Combined with proper logging, you'll have full visibility into every failure in your application.