Error Handling
Robust error handling patterns for Node.js — custom error classes, async wrapper utilities, Express global error middleware, Winston structured logging, and process-level safety nets.
Custom AppError Class
Extend the native Error class to carry an HTTP status code and an isOperational flag. Operational errors (e.g. 404, validation) are safe to expose to clients; programmer errors crash the process.
// lib/AppError.js
class AppError extends Error {
/**
* @param {string} message - human-readable description
* @param {number} statusCode - HTTP status (e.g. 400, 404, 422)
* @param {string} [code] - machine-readable error code
* @param {object} [details] - extra payload (validation errors, etc.)
*/
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR', details = null) {
super(message);
this.name = 'AppError';
this.statusCode = statusCode;
this.code = code;
this.details = details;
this.isOperational = true; // flag: safe to expose to client
Error.captureStackTrace(this, this.constructor);
}
}
// Convenience factories
AppError.notFound = (msg = 'Resource not found') =>
new AppError(msg, 404, 'NOT_FOUND');
AppError.unauthorized = (msg = 'Unauthorized') =>
new AppError(msg, 401, 'UNAUTHORIZED');
AppError.forbidden = (msg = 'Forbidden') =>
new AppError(msg, 403, 'FORBIDDEN');
AppError.badRequest = (msg, details) =>
new AppError(msg, 400, 'BAD_REQUEST', details);
AppError.conflict = (msg) =>
new AppError(msg, 409, 'CONFLICT');
module.exports = AppError;Async Error Wrapper (catchAsync)
Instead of wrapping every async route in a try/catch, use a higher-order function to forward errors to Express's next() automatically.
// lib/catchAsync.js
/**
* Wraps an async Express route handler and forwards any rejection to next().
* Usage: router.get('/path', catchAsync(async (req, res) => { ... }))
*/
const catchAsync = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
module.exports = catchAsync;
// ── Example usage in a route ──────────────────────────────────
const catchAsync = require('../lib/catchAsync');
const AppError = require('../lib/AppError');
router.get('/users/:id', catchAsync(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw AppError.notFound(`User ${req.params.id} not found`);
res.json(user);
}));
router.post('/users', catchAsync(async (req, res) => {
const user = await User.create(req.body); // validation throws AppError
res.status(201).json(user);
}));Global Express Error Handling Middleware
Register this as the last middleware (after all routes). It catches everything forwarded via next(err). Never expose stack traces in production.
// middleware/errorHandler.js
const AppError = require('../lib/AppError');
const logger = require('../lib/logger');
// eslint-disable-next-line no-unused-vars
module.exports = function errorHandler(err, req, res, next) {
// Normalize non-AppError errors
if (!(err instanceof AppError)) {
logger.error('Unhandled error', {
message: err.message,
stack: err.stack,
url: req.originalUrl,
method: req.method,
});
err = new AppError('Something went wrong', 500);
} else {
logger.warn('Operational error', {
code: err.code,
message: err.message,
url: req.originalUrl,
});
}
const response = {
status: 'error',
code: err.code,
message: err.message,
};
if (err.details) response.details = err.details;
if (process.env.NODE_ENV !== 'production') response.stack = err.stack;
res.status(err.statusCode).json(response);
};
// ── Register in app.js (after all routes) ─────────────────────
// const errorHandler = require('./middleware/errorHandler');
// app.use(errorHandler);Winston Structured Logger
Configure Winston to write JSON logs in production (easy to parse by log aggregators) and colourized text in development.
npm install winston
// lib/logger.js
const { createLogger, format, transports } = require('winston');
const { combine, timestamp, errors, json, colorize, simple } = format;
const isProd = process.env.NODE_ENV === 'production';
const logger = createLogger({
level: process.env.LOG_LEVEL || (isProd ? 'info' : 'debug'),
format: isProd
? combine(timestamp(), errors({ stack: true }), json())
: combine(colorize(), timestamp({ format: 'HH:mm:ss' }), errors({ stack: true }), simple()),
defaultMeta: { service: 'api' },
transports: [
new transports.Console(),
...(isProd ? [
new transports.File({ filename: 'logs/error.log', level: 'error' }),
new transports.File({ filename: 'logs/combined.log' }),
] : []),
],
exceptionHandlers: [new transports.File({ filename: 'logs/exceptions.log' })],
rejectionHandlers: [new transports.File({ filename: 'logs/rejections.log' })],
});
module.exports = logger;
// Usage:
// logger.info('Server started', { port: 3000 });
// logger.error('DB connection failed', { error: err.message });
// logger.warn('Cache miss', { key });Process-Level Safety Nets
Catch any errors that slip through try/catch or Promise chains. Log them, then exit cleanly so the process manager (PM2, Docker) can restart.
// server.js (entry point – register these before anything else)
const logger = require('./lib/logger');
process.on('uncaughtException', (err) => {
logger.error('UNCAUGHT EXCEPTION – shutting down', {
name: err.name,
message: err.message,
stack: err.stack,
});
// Give logger time to flush, then exit
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
logger.error('UNHANDLED REJECTION – shutting down', {
promise,
reason: reason?.message || reason,
stack: reason?.stack,
});
// Throw to trigger uncaughtException handler above
throw reason;
});
// ── Start server after registering handlers ───────────────────
const app = require('./app');
const server = app.listen(process.env.PORT || 3000, () =>
logger.info(`Server listening on port ${process.env.PORT || 3000}`)
);
// Graceful shutdown on SIGTERM (Docker, PM2)
process.on('SIGTERM', () => {
logger.info('SIGTERM received – closing HTTP server');
server.close(() => {
logger.info('HTTP server closed');
process.exit(0);
});
});