Error Handling Strategy
Production error handling has two goals: give clients useful error responses and give your team actionable debugging information.
Custom Error Classes
class AppError extends Error {
constructor(message, statusCode, code) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = true; // Expected errors (not bugs)
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
class ValidationError extends AppError {
constructor(details) {
super('Validation failed', 400, 'VALIDATION_ERROR');
this.details = details;
}
}
class AuthenticationError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401, 'AUTH_REQUIRED');
}
}
class ForbiddenError extends AppError {
constructor(message = 'Insufficient permissions') {
super(message, 403, 'FORBIDDEN');
}
}
class ConflictError extends AppError {
constructor(message = 'Resource already exists') {
super(message, 409, 'CONFLICT');
}
}
module.exports = { AppError, NotFoundError, ValidationError, AuthenticationError, ForbiddenError, ConflictError };Using Custom Errors in Routes
const { NotFoundError, ValidationError } = require('../errors');
async function getUser(req, res) {
const user = await db.users.findById(req.params.id);
if (!user) {
throw new NotFoundError('User');
}
res.json(user);
}
async function createUser(req, res) {
const existing = await db.users.findByEmail(req.body.email);
if (existing) {
throw new ConflictError('Email already registered');
}
const user = await db.users.create(req.body);
res.status(201).json(user);
}Async Error Handling
Express doesn’t catch errors from async functions by default. Use a wrapper.
// Option 1: Wrapper function
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
router.get('/users/:id', asyncHandler(async (req, res) => {
const user = await db.users.findById(req.params.id);
if (!user) throw new NotFoundError('User');
res.json(user);
}));
// Option 2: express-async-errors (patches Express globally)
require('express-async-errors');
// Now async errors are automatically forwarded to error middleware
router.get('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
if (!user) throw new NotFoundError('User');
res.json(user);
});Centralized Error Middleware
function errorHandler(err, req, res, next) {
// Log the error
if (err.isOperational) {
req.log.warn({ err, requestId: req.id }, err.message);
} else {
req.log.error({ err, requestId: req.id }, 'Unexpected error');
}
// Determine response
const statusCode = err.statusCode || 500;
const response = {
error: {
code: err.code || 'INTERNAL_ERROR',
message: err.isOperational ? err.message : 'Something went wrong',
...(err.details && { details: err.details }),
...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
},
requestId: req.id,
};
res.status(statusCode).json(response);
}
// Must be registered AFTER all routes
app.use(errorHandler);Unhandled Rejections and Uncaught Exceptions
process.on('unhandledRejection', (reason, promise) => {
logger.fatal({ reason }, 'Unhandled Promise Rejection');
// In production, crash and let process manager restart
process.exit(1);
});
process.on('uncaughtException', (error) => {
logger.fatal({ error }, 'Uncaught Exception');
// Give logger time to flush, then exit
setTimeout(() => process.exit(1), 1000);
});
// Graceful shutdown on SIGTERM
process.on('SIGTERM', async () => {
logger.info('SIGTERM received, shutting down...');
server.close(async () => {
await db.close();
process.exit(0);
});
// Force exit after 10s
setTimeout(() => process.exit(1), 10000);
});Structured Logging with Pino
Pino is the fastest Node.js logger — 5x faster than Winston for JSON output.
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
formatters: {
level(label) {
return { level: label }; // Use string labels, not numbers
},
},
serializers: {
err: pino.stdSerializers.err,
req: (req) => ({
method: req.method,
url: req.url,
remoteAddress: req.ip,
}),
},
// Pretty print in development
...(process.env.NODE_ENV !== 'production' && {
transport: {
target: 'pino-pretty',
options: { colorize: true, translateTime: 'SYS:standard' },
},
}),
});Request Logging with Correlation IDs
const { v4: uuid } = require('uuid');
// Attach request ID and child logger to every request
app.use((req, res, next) => {
req.id = req.headers['x-request-id'] || uuid();
req.log = logger.child({ requestId: req.id });
res.setHeader('x-request-id', req.id);
next();
});
// Request/Response logging
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
const logData = {
method: req.method,
url: req.originalUrl,
status: res.statusCode,
duration,
contentLength: res.getHeader('content-length'),
};
if (res.statusCode >= 500) {
req.log.error(logData, 'request error');
} else if (res.statusCode >= 400) {
req.log.warn(logData, 'request warning');
} else {
req.log.info(logData, 'request completed');
}
});
next();
});Winston Alternative
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json(),
),
defaultMeta: { service: 'api-service' },
transports: [
new winston.transports.Console({
format: process.env.NODE_ENV !== 'production'
? winston.format.combine(winston.format.colorize(), winston.format.simple())
: winston.format.json(),
}),
// File transport for errors
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
maxsize: 10 * 1024 * 1024, // 10MB
maxFiles: 5,
}),
],
});Log Levels Guide
| Level | When to Use | Example |
|---|---|---|
| fatal | App is about to crash | Uncaught exception, out of memory |
| error | Operation failed, needs attention | Database connection failed, payment failed |
| warn | Unexpected but handled | Rate limit hit, deprecated API used |
| info | Normal operations | Request completed, user logged in |
| debug | Development diagnostics | SQL query, cache hit/miss |
| trace | Granular debugging | Function entry/exit, variable values |
Production Logging Checklist
- Always log structured JSON — parseable by log aggregators
- Include correlation IDs — trace requests across services
- Never log sensitive data — passwords, tokens, PII
- Set appropriate log levels —
infoin production,debugin development - Use child loggers — inherit context (requestId, userId)
- Rotate logs — use file size limits or external log shipping
- Alert on error rate spikes — integrate with monitoring tools
// NEVER log this
logger.info({ password: user.password }); // Leaks credentials
// Log this instead
logger.info({ userId: user.id, email: user.email }, 'User created');