Node.js Error Handling Best Practices
Error handling is one of the most critical yet often overlooked aspects of Node.js development. Poor error handling leads to crashed applications, security vulnerabilities, and frustrated users. In production environments, how you handle errors can mean the difference between a minor hiccup and a complete system failure.
This comprehensive guide covers everything you need to know about handling errors in Node.js—from understanding error types and using try-catch effectively, to building centralized error handling systems, implementing graceful shutdowns, and logging errors for debugging. By the end, you'll have a production-ready error handling strategy.
- Understanding operational vs programmer errors
- Creating custom error classes for different scenarios
- Handling synchronous and asynchronous errors properly
- Building centralized error handling middleware for Express
- Implementing graceful shutdown for production
- Error logging and monitoring strategies
- Avoiding common error handling anti-patterns
Understanding Error Types in Node.js
Before diving into handling strategies, it's crucial to understand that not all errors are created equal. Node.js errors generally fall into two categories, and each requires a different approach.
Understanding error types is essential for choosing the right handling strategy
Operational Errors
Runtime problems that occur in correctly written programs. These are expected and should be handled gracefully—your application should recover and continue serving requests.
Programmer Errors
Bugs in your code that need to be fixed. These indicate logical errors and often leave your application in an unpredictable state. The safest approach is usually to crash and restart.
Creating Custom Error Classes
The built-in Error class is limited. Creating custom error classes allows you to add context, status codes, and distinguish between different error types programmatically.
// Base application error class
class AppError extends Error {
constructor(message, statusCode, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
this.isOperational = isOperational;
this.timestamp = new Date().toISOString();
// Capture stack trace, excluding constructor
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
Now let's create specific error types for common scenarios:
const AppError = require('./AppError');
// 400 Bad Request - Invalid input
class ValidationError extends AppError {
constructor(message, errors = []) {
super(message || 'Validation failed', 400);
this.name = 'ValidationError';
this.errors = errors; // Array of field-specific errors
}
}
// 401 Unauthorized - Not authenticated
class AuthenticationError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401);
this.name = 'AuthenticationError';
}
}
// 403 Forbidden - Not authorized
class ForbiddenError extends AppError {
constructor(message = 'Access denied') {
super(message, 403);
this.name = 'ForbiddenError';
}
}
// 404 Not Found
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404);
this.name = 'NotFoundError';
}
}
// 409 Conflict - Duplicate entry
class ConflictError extends AppError {
constructor(message = 'Resource already exists') {
super(message, 409);
this.name = 'ConflictError';
}
}
// 429 Too Many Requests
class RateLimitError extends AppError {
constructor(retryAfter = 60) {
super('Too many requests, please try again later', 429);
this.name = 'RateLimitError';
this.retryAfter = retryAfter;
}
}
// 500 Internal Server Error
class InternalError extends AppError {
constructor(message = 'Something went wrong') {
super(message, 500);
this.name = 'InternalError';
}
}
// 503 Service Unavailable - External service down
class ServiceUnavailableError extends AppError {
constructor(service = 'External service') {
super(`${service} is temporarily unavailable`, 503);
this.name = 'ServiceUnavailableError';
}
}
module.exports = {
AppError,
ValidationError,
AuthenticationError,
ForbiddenError,
NotFoundError,
ConflictError,
RateLimitError,
InternalError,
ServiceUnavailableError
};
The isOperational flag helps your error handler distinguish between expected errors (that can be safely returned to users) and unexpected bugs (that should trigger alerts and possibly restart the process).
Handling Synchronous Errors
For synchronous code, the traditional try-catch block is your primary tool. However, there are patterns to make it more effective.
const { ValidationError } = require('./errors');
function parseJSON(jsonString) {
try {
return JSON.parse(jsonString);
} catch (error) {
// Transform native error into our custom error
throw new ValidationError('Invalid JSON format');
}
}
function validateUser(userData) {
const errors = [];
if (!userData.email) {
errors.push({ field: 'email', message: 'Email is required' });
} else if (!isValidEmail(userData.email)) {
errors.push({ field: 'email', message: 'Invalid email format' });
}
if (!userData.password || userData.password.length < 8) {
errors.push({
field: 'password',
message: 'Password must be at least 8 characters'
});
}
if (errors.length > 0) {
throw new ValidationError('Validation failed', errors);
}
return true;
}
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
Handling Asynchronous Errors
Asynchronous error handling is where many Node.js applications fail. With Promises and async/await, you have elegant patterns available, but you must use them correctly.
Using async/await with try-catch
const { NotFoundError, ServiceUnavailableError } = require('./errors');
async function getUser(userId) {
try {
const user = await db.users.findById(userId);
if (!user) {
throw new NotFoundError('User');
}
return user;
} catch (error) {
// Re-throw our custom errors
if (error.isOperational) {
throw error;
}
// Transform database errors
if (error.code === 'ECONNREFUSED') {
throw new ServiceUnavailableError('Database');
}
// Unknown error - let it bubble up
throw error;
}
}
// Usage in Express route
app.get('/users/:id', async (req, res, next) => {
try {
const user = await getUser(req.params.id);
res.json(user);
} catch (error) {
next(error); // Pass to error handling middleware
}
});
The asyncHandler Wrapper Pattern
Writing try-catch in every route handler is tedious. Create a wrapper to handle it automatically:
/**
* Wraps async route handlers to automatically catch errors
* and pass them to Express error handling middleware
*/
const asyncHandler = (fn) => (req, res, next) => {
Promise
.resolve(fn(req, res, next))
.catch(next);
};
module.exports = asyncHandler;
// Usage - no try-catch needed!
const asyncHandler = require('./middleware/asyncHandler');
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 user = await createUser(req.body);
res.status(201).json(user);
}));
asyncHandler automatically catches errors and forwards them to Express error middleware
Centralized Error Handling Middleware
All roads lead to Rome—and all errors should lead to your centralized error handler. This pattern keeps error handling consistent and maintainable.
const { AppError } = require('../errors');
const logger = require('../utils/logger');
/**
* Development error response - includes stack trace
*/
const sendErrorDev = (err, res) => {
res.status(err.statusCode).json({
status: err.status,
error: err,
message: err.message,
stack: err.stack
});
};
/**
* Production error response - hides internal details
*/
const sendErrorProd = (err, res) => {
// Operational, trusted error: send message to client
if (err.isOperational) {
res.status(err.statusCode).json({
status: err.status,
message: err.message,
...(err.errors && { errors: err.errors }), // Include validation errors
...(err.retryAfter && { retryAfter: err.retryAfter })
});
} else {
// Programming or unknown error: don't leak details
logger.error('UNEXPECTED ERROR', err);
res.status(500).json({
status: 'error',
message: 'Something went wrong. Please try again later.'
});
}
};
/**
* Handle specific error types and convert to AppError
*/
const handleCastErrorDB = (err) => {
return new AppError(`Invalid ${err.path}: ${err.value}`, 400);
};
const handleDuplicateFieldsDB = (err) => {
const value = err.errmsg?.match(/(["'])(\\?.)*?\1/)?.[0];
return new AppError(`Duplicate field value: ${value}`, 400);
};
const handleValidationErrorDB = (err) => {
const errors = Object.values(err.errors).map(el => el.message);
return new AppError(`Invalid input: ${errors.join('. ')}`, 400);
};
const handleJWTError = () =>
new AppError('Invalid token. Please log in again.', 401);
const handleJWTExpiredError = () =>
new AppError('Your token has expired. Please log in again.', 401);
/**
* Global error handling middleware
*/
module.exports = (err, req, res, next) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || 'error';
if (process.env.NODE_ENV === 'development') {
sendErrorDev(err, res);
} else {
// Create a copy to avoid mutating original
let error = { ...err, message: err.message, name: err.name };
// Handle specific error types
if (error.name === 'CastError') error = handleCastErrorDB(error);
if (error.code === 11000) error = handleDuplicateFieldsDB(error);
if (error.name === 'ValidationError') error = handleValidationErrorDB(error);
if (error.name === 'JsonWebTokenError') error = handleJWTError();
if (error.name === 'TokenExpiredError') error = handleJWTExpiredError();
sendErrorProd(error, res);
}
};
Integrating the Error Handler
const express = require('express');
const errorHandler = require('./middleware/errorHandler');
const { NotFoundError } = require('./errors');
const app = express();
// Body parsing middleware
app.use(express.json());
// Your routes
app.use('/api/users', userRoutes);
app.use('/api/products', productRoutes);
// Handle 404 - Route not found
app.all('*', (req, res, next) => {
next(new NotFoundError(`Route ${req.originalUrl}`));
});
// Global error handler - MUST be last
app.use(errorHandler);
module.exports = app;
The error handling middleware must be the last middleware registered. Express identifies error-handling middleware by the four-parameter signature (err, req, res, next).
Handling Uncaught Exceptions and Rejections
Even with the best error handling, some errors slip through—uncaught exceptions and unhandled promise rejections. These are your safety net, and they need special attention in production.
const app = require('./app');
const logger = require('./utils/logger');
/**
* Handle uncaught exceptions
* These are synchronous errors that weren't caught
* MUST be registered before any other code runs
*/
process.on('uncaughtException', (error) => {
logger.error('UNCAUGHT EXCEPTION! 💥 Shutting down...', {
name: error.name,
message: error.message,
stack: error.stack
});
// Exit immediately - process is in undefined state
process.exit(1);
});
const PORT = process.env.PORT || 3000;
const server = app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
});
/**
* Handle unhandled promise rejections
* These are async errors that weren't caught
*/
process.on('unhandledRejection', (reason, promise) => {
logger.error('UNHANDLED REJECTION! 💥 Shutting down...', {
reason: reason?.message || reason,
stack: reason?.stack
});
// Graceful shutdown - finish existing requests first
server.close(() => {
process.exit(1);
});
});
/**
* Handle SIGTERM (e.g., from Kubernetes, Docker, Heroku)
*/
process.on('SIGTERM', () => {
logger.info('SIGTERM received. Performing graceful shutdown...');
server.close(() => {
logger.info('Process terminated gracefully');
process.exit(0);
});
});
Graceful shutdown ensures no requests are lost during restarts or scaling
Error Logging Best Practices
Good logging is essential for debugging production issues. Here's a structured logging utility that works well with error handling:
const winston = require('winston');
const levels = {
error: 0,
warn: 1,
info: 2,
http: 3,
debug: 4,
};
const colors = {
error: 'red',
warn: 'yellow',
info: 'green',
http: 'magenta',
debug: 'blue',
};
winston.addColors(colors);
const format = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }), // Include stack trace
winston.format.json()
);
const devFormat = winston.format.combine(
winston.format.colorize({ all: true }),
winston.format.timestamp({ format: 'HH:mm:ss' }),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
let msg = `${timestamp} [${level}]: ${message}`;
if (Object.keys(meta).length) {
msg += ` ${JSON.stringify(meta, null, 2)}`;
}
return msg;
})
);
const transports = [
// Console output
new winston.transports.Console({
format: process.env.NODE_ENV === 'development' ? devFormat : format,
}),
// Error log file
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
format,
maxsize: 5242880, // 5MB
maxFiles: 5,
}),
// Combined log file
new winston.transports.File({
filename: 'logs/combined.log',
format,
maxsize: 5242880,
maxFiles: 5,
}),
];
const logger = winston.createLogger({
level: process.env.NODE_ENV === 'development' ? 'debug' : 'info',
levels,
transports,
});
module.exports = logger;
Structured Error Logging
const logger = require('./utils/logger');
// Log with context for easier debugging
const logError = (error, req = null) => {
const errorLog = {
message: error.message,
name: error.name,
statusCode: error.statusCode,
isOperational: error.isOperational,
stack: error.stack,
};
if (req) {
errorLog.request = {
method: req.method,
url: req.originalUrl,
ip: req.ip,
userId: req.user?.id,
userAgent: req.get('user-agent'),
body: sanitizeBody(req.body), // Remove sensitive fields
};
}
logger.error('Request failed', errorLog);
};
// Remove sensitive data from logs
const sanitizeBody = (body) => {
const sanitized = { ...body };
const sensitiveFields = ['password', 'token', 'creditCard', 'ssn'];
sensitiveFields.forEach(field => {
if (sanitized[field]) {
sanitized[field] = '[REDACTED]';
}
});
return sanitized;
};
Database Error Handling
Database operations are a common source of errors. Here's how to handle them properly with different databases:
const {
NotFoundError,
ValidationError,
ConflictError
} = require('./errors');
async function createUser(userData) {
try {
const user = await User.create(userData);
return user;
} catch (error) {
// Handle duplicate key error (MongoDB code 11000)
if (error.code === 11000) {
const field = Object.keys(error.keyValue)[0];
throw new ConflictError(
`A user with this ${field} already exists`
);
}
// Handle Mongoose validation errors
if (error.name === 'ValidationError') {
const errors = Object.values(error.errors).map(e => ({
field: e.path,
message: e.message
}));
throw new ValidationError('Validation failed', errors);
}
// Handle CastError (invalid ObjectId)
if (error.name === 'CastError') {
throw new ValidationError(
`Invalid ${error.path}: ${error.value}`
);
}
throw error;
}
}
const { Sequelize } = require('sequelize');
const {
ValidationError,
ConflictError,
ServiceUnavailableError
} = require('./errors');
async function createUser(userData) {
try {
const user = await User.create(userData);
return user;
} catch (error) {
// Unique constraint violation
if (error instanceof Sequelize.UniqueConstraintError) {
const field = error.errors[0]?.path;
throw new ConflictError(
`A user with this ${field} already exists`
);
}
// Validation error
if (error instanceof Sequelize.ValidationError) {
const errors = error.errors.map(e => ({
field: e.path,
message: e.message
}));
throw new ValidationError('Validation failed', errors);
}
// Connection error
if (error instanceof Sequelize.ConnectionError) {
throw new ServiceUnavailableError('Database');
}
throw error;
}
}
const { Prisma } = require('@prisma/client');
const {
NotFoundError,
ConflictError,
ValidationError
} = require('./errors');
async function createUser(userData) {
try {
const user = await prisma.user.create({
data: userData
});
return user;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
// Unique constraint violation
if (error.code === 'P2002') {
const field = error.meta?.target?.[0];
throw new ConflictError(
`A user with this ${field} already exists`
);
}
// Record not found
if (error.code === 'P2025') {
throw new NotFoundError('Record');
}
}
if (error instanceof Prisma.PrismaClientValidationError) {
throw new ValidationError('Invalid data provided');
}
throw error;
}
}
External API Error Handling
When calling external APIs, you need to handle network errors, timeouts, and API-specific errors:
const axios = require('axios');
const {
ServiceUnavailableError,
RateLimitError,
AppError
} = require('../errors');
const apiClient = axios.create({
baseURL: process.env.EXTERNAL_API_URL,
timeout: 10000, // 10 seconds
headers: {
'Content-Type': 'application/json',
},
});
// Response interceptor for error handling
apiClient.interceptors.response.use(
(response) => response,
(error) => {
// Network error or timeout
if (!error.response) {
if (error.code === 'ECONNABORTED') {
throw new ServiceUnavailableError('Request timeout');
}
throw new ServiceUnavailableError('Network error');
}
const { status, data } = error.response;
// Rate limiting
if (status === 429) {
const retryAfter = error.response.headers['retry-after'] || 60;
throw new RateLimitError(parseInt(retryAfter));
}
// Service unavailable
if (status >= 500) {
throw new ServiceUnavailableError('External API');
}
// Client errors - pass through the message
throw new AppError(
data.message || 'External API error',
status
);
}
);
// Retry logic with exponential backoff
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await apiClient.get(url, options);
} catch (error) {
lastError = error;
// Only retry on network errors or 5xx
if (!error.isOperational || error.statusCode < 500) {
throw error;
}
// Exponential backoff
const delay = Math.pow(2, attempt) * 1000;
await new Promise(r => setTimeout(r, delay));
}
}
throw lastError;
}
module.exports = { apiClient, fetchWithRetry };
Common Anti-Patterns to Avoid
Let's look at what NOT to do when handling errors in Node.js:
Anti-Pattern #1: Silent Error Swallowing
// Error disappears into the void!
try {
await riskyOperation();
} catch (error) {
// Silently ignoring the error
}
try {
await riskyOperation();
} catch (error) {
logger.error('Operation failed', { error });
throw error; // Or handle appropriately
}
Anti-Pattern #2: Using throw with strings
// No stack trace, not an Error instance
throw 'User not found';
// Proper Error with stack trace
throw new NotFoundError('User');
Anti-Pattern #3: Not handling Promise rejections
// Unhandled promise rejection!
getUserData(userId).then(user => {
processUser(user);
});
// Handle the rejection
getUserData(userId)
.then(user => processUser(user))
.catch(error => handleError(error));
// Or use async/await with try-catch
try {
const user = await getUserData(userId);
processUser(user);
} catch (error) {
handleError(error);
}
Anti-Pattern #4: Exposing internal errors to users
// Leaking sensitive info!
res.status(500).json({
error: error.message,
stack: error.stack,
query: 'SELECT * FROM users WHERE id = 123'
});
// Log internally, send generic message
logger.error('Database error', { error, query });
res.status(500).json({
status: 'error',
message: 'Something went wrong'
});
Production Error Handling Checklist
Before deploying to production, ensure your error handling meets these criteria:
Production Readiness Checklist
Custom Error Classes
Create hierarchy with operational flag, status codes, and proper stack traces.
Centralized Error Handler
Single middleware handling all errors consistently with dev/prod modes.
Async Handler Wrapper
Eliminate try-catch boilerplate in routes with asyncHandler pattern.
Process-Level Handlers
Handle uncaughtException, unhandledRejection, and SIGTERM signals.
Structured Logging
Log errors with context, sanitize sensitive data, use log aggregation.
External Service Handling
Implement timeouts, retries with backoff, and circuit breakers.
Error Monitoring
Integrate with Sentry, Datadog, or similar for real-time alerts.
Complete Error Handling Setup
Here's the recommended project structure for a production-ready error handling setup:
src/
├── errors/
│ ├── index.js # Export all error classes
│ └── AppError.js # Base error class
├── middleware/
│ ├── asyncHandler.js # Async wrapper
│ └── errorHandler.js # Centralized handler
├── utils/
│ └── logger.js # Winston logger
├── app.js # Express app setup
└── server.js # Server + process handlers
Remember: fail fast, fail loud. It's better to crash early and recover than to continue in an undefined state. Use process managers like PM2 or container orchestrators to automatically restart your application when it crashes.
Summary
Robust error handling is what separates amateur Node.js applications from production-grade systems. Let's recap the key takeaways:
| Concept | Key Points |
|---|---|
| Error Types | Distinguish between operational (handle) and programmer (fix) errors |
| Custom Errors | Create a hierarchy with isOperational, statusCode, and proper stack traces |
| Async Errors | Use asyncHandler wrapper to eliminate try-catch boilerplate |
| Centralized Handler | Single error middleware with dev/prod modes and error transformation |
| Process Handlers | Handle uncaughtException, unhandledRejection, SIGTERM |
| Logging | Structured logs with context, sanitized sensitive data |
Implementing these patterns will give you a solid foundation for building reliable Node.js applications. Your future self (and your ops team) will thank you when debugging production issues becomes straightforward rather than a nightmare.
