Good error handling isn’t defensive programming — it’s information design. Every unhandled error in production is a bug in your error strategy: either you didn’t anticipate the failure, didn’t log enough context to debug it, or didn’t communicate it clearly to clients. Building a solid error and logging foundation early prevents an entire class of production incidents.
Designing an Error Hierarchy
The first step is distinguishing operational errors from programmer errors. This distinction matters because they require different responses:
Operational errors are expected failures: network timeouts, validation failures, resource not found, authentication required. Handle them explicitly, return appropriate HTTP status codes, and log at an appropriate level.
Programmer errors are bugs: null pointer dereferences, type mismatches, assertion failures. Let the process crash (or restart via a process manager), and capture the full stack trace for debugging.
Build a typed error hierarchy to represent operational errors:
// src/errors/index.js
class AppError extends Error {
constructor(message, { status = 500, code = null, details = null } = {}) {
super(message);
this.name = this.constructor.name;
this.status = status;
this.code = code;
this.details = details;
this.isOperational = true; // Marks this as a handled error, not a bug
Error.captureStackTrace(this, this.constructor);
}
}
class ValidationError extends AppError {
constructor(message, details = null) {
super(message, { status: 400, code: 'VALIDATION_ERROR', details });
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, { status: 404, code: 'NOT_FOUND' });
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Authentication required') {
super(message, { status: 401, code: 'UNAUTHORIZED' });
}
}
class ForbiddenError extends AppError {
constructor(message = 'Access denied') {
super(message, { status: 403, code: 'FORBIDDEN' });
}
}
class ConflictError extends AppError {
constructor(message, code = 'CONFLICT') {
super(message, { status: 409, code });
}
}
module.exports = { AppError, ValidationError, NotFoundError, UnauthorizedError, ForbiddenError, ConflictError };
Using named error classes means instanceof checks work correctly, and stack traces show the specific error type in the first line.
Express Error Handling Middleware
Express’s four-argument error handler catches everything passed to next(err):
// src/middleware/errorHandler.js
const { AppError } = require('../errors');
const logger = require('../lib/logger');
const errorHandler = (err, req, res, next) => {
// Attach request context to every error log
const context = {
requestId: req.id,
method: req.method,
url: req.originalUrl,
userId: req.user?.id,
};
// Operational errors are expected — log at warn level, return structured response
if (err.isOperational) {
logger.warn({ msg: err.message, code: err.code, ...context });
return res.status(err.status).json({
success: false,
error: {
message: err.message,
code: err.code,
...(err.details && { details: err.details }),
},
});
}
// Handle framework-specific errors before treating them as bugs
if (err.name === 'ValidationError') {
// Mongoose model validation failed
const details = Object.values(err.errors).map(e => ({ field: e.path, message: e.message }));
logger.warn({ msg: 'Mongoose validation error', ...context });
return res.status(400).json({
success: false,
error: { message: 'Validation failed', code: 'VALIDATION_ERROR', details }
});
}
if (err.name === 'CastError') {
return res.status(400).json({ success: false, error: { message: 'Invalid ID format' } });
}
if (err.code === 11000) {
// MongoDB duplicate key
const field = Object.keys(err.keyValue ?? {})[0] ?? 'field';
logger.warn({ msg: 'Duplicate key', field, ...context });
return res.status(409).json({
success: false,
error: { message: `${field} already in use`, code: 'DUPLICATE_KEY' }
});
}
// Programmer error — log full stack trace and return generic 500
logger.error({ msg: err.message, stack: err.stack, ...context });
return res.status(500).json({
success: false,
error: { message: 'Internal server error' }
});
};
// Wrap async route handlers — passes any rejected promise to the error handler
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
module.exports = { errorHandler, asyncHandler };
Use the asyncHandler wrapper on every async route to avoid unhandled promise rejections:
const { asyncHandler } = require('../middleware/errorHandler');
const { NotFoundError } = require('../errors');
const User = require('../models/User');
router.get('/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id).lean();
if (!user) throw new NotFoundError('User');
res.json({ success: true, data: user });
}));
Structured Logging with Winston
Plain console.log output is hard to search in production. Structured logging emits JSON objects, which log aggregators (Datadog, CloudWatch, Elastic) can index and query — you can filter by userId, requestId, or errorCode directly.
Install Winston:
npm install winston
Configure a logger with appropriate transports for each environment:
// src/lib/logger.js
const winston = require('winston');
const { combine, timestamp, json, colorize, simple, errors } = winston.format;
// Base formats — JSON with timestamps and proper error serialization
const productionFormat = combine(
timestamp(),
errors({ stack: true }), // Include stack traces in error logs
json()
);
// Human-readable format for local development
const developmentFormat = combine(
colorize(),
timestamp({ format: 'HH:mm:ss' }),
errors({ stack: true }),
simple()
);
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: process.env.NODE_ENV === 'production' ? productionFormat : developmentFormat,
defaultMeta: {
service: process.env.SERVICE_NAME || 'api',
env: process.env.NODE_ENV || 'development',
},
transports: [
new winston.transports.Console(),
// In production, also write errors to a persistent file
...(process.env.NODE_ENV === 'production' ? [
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
] : [])
],
// Don't crash the process if a logger transport fails
exitOnError: false,
});
module.exports = logger;
Structured log output in production:
{
"level": "warn",
"msg": "User not found",
"code": "NOT_FOUND",
"requestId": "req-abc123",
"userId": "user-42",
"method": "GET",
"url": "/api/users/999",
"service": "api",
"env": "production",
"timestamp": "2026-08-30T14:22:11.453Z"
}
Request logging middleware captures every inbound request with timing:
// src/middleware/requestLogger.js
const { v4: uuid } = require('uuid');
const logger = require('../lib/logger');
const requestLogger = (req, res, next) => {
req.id = uuid();
req.start = Date.now();
res.on('finish', () => {
const duration = Date.now() - req.start;
const level = res.statusCode >= 500 ? 'error'
: res.statusCode >= 400 ? 'warn'
: 'info';
logger[level]({
msg: 'HTTP request',
requestId: req.id,
method: req.method,
url: req.originalUrl,
status: res.statusCode,
durationMs: duration,
userId: req.user?.id,
});
});
next();
};
module.exports = requestLogger;
Sentry Integration
Sentry captures uncaught exceptions and provides stack traces, request context, and a dashboard for triaging issues. In production, it’s invaluable for bugs that slip through error handling.
npm install @sentry/node
Initialize Sentry before any other imports (it patches Node.js internals):
// src/instrument.js — import this file first in your entry point
const Sentry = require('@sentry/node');
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0, // 10% of requests in prod
beforeSend(event) {
// Don't send events for operational errors — only bugs
if (event.exception?.values?.[0]?.type && event.tags?.isOperational) {
return null;
}
return event;
},
});
module.exports = Sentry;
// src/server.js
require('./instrument'); // Must be first
const app = require('./app');
// ...
Add Sentry to your Express error handler — it needs to run before your custom handler:
// app.js
const Sentry = require('@sentry/node');
// Request handler must be the first middleware
app.use(Sentry.Handlers.requestHandler());
app.use('/api', routes);
// Error handler must come before your custom error handler
app.use(Sentry.Handlers.errorHandler({
shouldHandleError(error) {
// Only capture programmer errors, not operational errors
return !error.isOperational;
}
}));
app.use(errorHandler); // Your custom error handler
Attach user context to Sentry events for easier debugging:
// In your auth middleware, after setting req.user:
Sentry.setUser({ id: req.user.id, email: req.user.email });
Health Check Endpoints
Health checks let load balancers and orchestrators know if your instance is ready to receive traffic. Kubernetes uses two types: liveness (is the process alive?) and readiness (can it serve traffic?).
// src/routes/health.js
const router = require('express').Router();
const mongoose = require('mongoose');
// Liveness check — is the process running and not deadlocked?
router.get('/live', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
// Readiness check — can this instance actually serve requests?
router.get('/ready', async (req, res) => {
const checks = {};
let isReady = true;
// Check database connectivity
try {
await mongoose.connection.db.command({ ping: 1 });
checks.database = 'ok';
} catch (err) {
checks.database = 'error';
isReady = false;
}
// Check cache (Redis) if applicable
// try { await redisClient.ping(); checks.cache = 'ok'; }
// catch { checks.cache = 'error'; isReady = false; }
res.status(isReady ? 200 : 503).json({
status: isReady ? 'ready' : 'not ready',
checks,
timestamp: new Date().toISOString(),
});
});
module.exports = router;
Mount these at the app level, not under /api, and exclude them from authentication middleware.
Handling Unhandled Rejections and Exceptions
Some errors escape Express middleware — unhandled promise rejections and uncaught exceptions. Handle them at the process level:
// src/server.js
process.on('unhandledRejection', (reason, promise) => {
logger.error({
msg: 'Unhandled promise rejection',
reason: reason instanceof Error ? reason.message : reason,
stack: reason instanceof Error ? reason.stack : undefined,
});
// Give in-flight requests time to complete before exiting
server.close(() => process.exit(1));
});
process.on('uncaughtException', (err) => {
logger.error({ msg: 'Uncaught exception', error: err.message, stack: err.stack });
// Uncaught exceptions leave the process in an undefined state — must exit
process.exit(1);
});
// Graceful shutdown on SIGTERM (from Docker/Kubernetes)
process.on('SIGTERM', () => {
logger.info('SIGTERM received, shutting down gracefully');
server.close(() => {
mongoose.connection.close();
logger.info('Shutdown complete');
process.exit(0);
});
});
Use a process manager like PM2 or a Kubernetes Deployment to automatically restart after crashes.
Summary
A production-ready error and logging strategy has these components in place before you ship:
- A typed error hierarchy that distinguishes operational errors from bugs
- Express middleware that handles each error type appropriately and never leaks stack traces to clients
- Structured JSON logging with request context on every log line
- Sentry (or equivalent) capturing only programmer errors with stack traces and user context
- Health check endpoints for
/liveand/readythat load balancers can probe - Process-level handlers for unhandled rejections and uncaught exceptions with graceful shutdown
Resources
- Winston documentation
- Sentry Node.js SDK
- Express error handling
- Node.js process events
- Kubernetes health checks
Comments