Node.js Error Handling Best Practices
A request hangs until the client times out. Or the API returns a 500 with a raw stack trace that includes a SQL query. Or the process just dies at 3am and the only log line is UnhandledPromiseRejection. Most of these come from the same place: errors that were never planned for, so each route deals with them differently or not at all.
This post walks through the setup I'd put in any Express app: separating operational errors from bugs, custom error classes, catching async errors without try-catch in every route, one central error middleware, process-level handlers with graceful shutdown, and logging that actually helps when something breaks.
- 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
Not every error deserves the same response. A user typing a bad email and a TypeError in your own code need very different handling, so it helps to sort errors into two buckets first.
Operational errors get handled; programmer errors get fixed
Operational Errors
Runtime problems that occur in correctly written programs. You know they'll happen, so handle them: return a sensible response and keep 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
A plain Error only carries a message and a stack. Once you need an HTTP status code or a way to tell "expected" from "bug" in your middleware, a small class hierarchy pays off.
// 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;
On top of that, one subclass per status code you actually return:
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 error handler checks isOperational to decide what to do. If it's true, the message is safe to show the user. If it's missing, you're looking at a bug: log it, alert someone, and send a generic 500.
Handling Synchronous Errors
Sync code is the easy case: plain try-catch. The useful habit is to catch low-level errors (like a SyntaxError from JSON.parse) and rethrow them as one of your own classes, so the middleware knows what status to send.
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
This is where most Node.js apps get it wrong. An error thrown inside an async function becomes a rejected promise, and if nothing awaits it with a catch, it never reaches your error handler.
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 gets old fast, and sooner or later someone forgets one. A tiny wrapper does it for you:
/**
* 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 catches the rejection and forwards it to the Express error middleware
Express 4 does not catch rejected promises from async handlers. Without a wrapper (or the express-async-errors package), the request just hangs until the client times out, and Node logs an unhandled rejection. Express 5 forwards rejected promises to next() on its own, so check which version you're on before adding the wrapper everywhere.
Centralized Error Handling Middleware
Every error should end up in one place. That way the response format, the logging, and the decision about what to hide from users live in a single file instead of being copied across routes.
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;
Register the error middleware last. Express recognizes it only by its four parameters, (err, req, res, next), so don't drop the unused next argument (a linter cleanup can do this for you). With three parameters Express treats it as a normal middleware and your errors fall through to the default HTML error page.
Handling Uncaught Exceptions and Rejections
Some errors will still get past everything above. These process-level handlers are the last line: log what happened, then shut down cleanly. Since Node 15, an unhandled rejection crashes the process by default anyway, so it's better to control how that happens.
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 lets in-flight requests finish before the process exits
Error Logging Best Practices
When something breaks in production, the logs are usually all you have. JSON logs with timestamps and stack traces are much easier to search than console.log output. Here's a Winston setup that does that:
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
Each ORM throws its own error shapes. The job is the same in all of them: turn duplicate keys, validation failures and lost connections into your own error classes.
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
Third-party APIs fail in three main ways: the network drops, the request times out, or the API returns an error status. Always set a timeout. Axios has no timeout by default, so a slow upstream can hold your request open indefinitely.
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
These show up in code reviews all the time:
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 shipping, I'd go through this list:
Production Readiness Checklist
Custom Error Classes
A small hierarchy with an operational flag, status codes, and proper stack traces.
Centralized Error Handler
Single middleware handling all errors consistently with dev/prod modes.
Async Handler Wrapper
No try-catch boilerplate in routes (or Express 5, which handles it for you).
Process-Level Handlers
Handle uncaughtException, unhandledRejection, and SIGTERM signals.
Structured Logging
Log errors with context, sanitize sensitive data, use log aggregation.
External Service Handling
Timeouts on every outbound call, retries with backoff, and a circuit breaker for flaky dependencies.
Error Monitoring
Integrate with Sentry, Datadog, or similar for real-time alerts.
Complete Error Handling Setup
Here's how the files from this post fit together:
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
Exiting on an uncaught exception only works if something brings the process back. Run under PM2, systemd, or a container orchestrator with a restart policy. Otherwise the "safe" crash just turns into downtime.
When I'd use all of this (and when I wouldn't)
The full setup makes sense for an API with several developers, real users, and an on-call rotation. For a small internal tool or a weekend project, a lot of it is ceremony. Here's what each piece costs and when I'd leave it out:
| Pattern | When I'd skip or simplify it |
|---|---|
| Error Types | Never skip the distinction itself. It decides whether you show a message or page someone. |
| Custom Errors | Eight classes is too many for a small app. AppError with a status code covers most cases. |
| Async Errors | On Express 5 you don't need asyncHandler at all. |
| Centralized Handler | Always worth it. Just keep the DB and JWT mapping list short, or it turns into a dumping ground. |
| Process Handlers | Don't use uncaughtException to keep the process alive. Log and exit, nothing more. |
| Logging | In containers, skip the log files and write JSON to stdout. Let the platform collect it. |
The mistake I see most often isn't missing classes or middleware. It's a catch block that logs and returns null, so the caller carries on with bad data and the real failure shows up three functions later. If you're not sure what to do with an error, rethrow it and let the central handler decide.