Error Handling in Node.js: Beyond Try/Catch (2026)
Try/catch is just the beginning. Real-world error handling is about resilience, observability, and graceful degradation.
The Error Handling Mindset
Bad error handling:
→ try/catch everything silently → errors disappear, bugs never found
→ console.error(err) and move on → logs nobody reads
→ crash the process → downtime, lost requests
→ Return generic "error" message → impossible to debug
Good error handling:
→ Categorize errors (operational vs programming vs external)
→ Each category gets different handling (retry, report, or fail gracefully)
→ Every error is logged with context (request ID, user, params)
→ Users get helpful messages; developers get diagnostic details
→ The system heals itself when possible
Custom Error Classes
// Base application error
class AppError extends Error {
constructor(message, { code = 'ERROR', statusCode = 500, details = null, cause = null } = {}) {
super(message);
this.name = this.constructor.name;
this.code = code; // Machine-readable error code
this.statusCode = statusCode; // HTTP status code
this.details = details; // Additional context
this.cause = cause; // Original error (error chaining)
// Capture stack trace (but clean it for production)
Error.captureStackTrace(this, this.constructor);
}
toJSON() {
return {
name: this.name,
message: this.message,
code: this.code,
statusCode: this.statusCode,
...(this.details && { details: this.details }),
};
}
}
// Specific error types:
class ValidationError extends AppError {
constructor(fields) {
super('Validation failed', {
code: 'VALIDATION_ERROR',
statusCode: 422,
details: fields, // [{ field: 'email', message: 'Invalid format' }]
});
}
}
class NotFoundError extends AppError {
constructor(resource, id) {
super(`${resource} not found`, {
code: `${resource.toUpperCase()}_NOT_FOUND`,
statusCode: 404,
details: { resource, id },
});
}
}
class AuthenticationError extends AppError {
constructor(message = 'Authentication required') {
super(message, {
code: 'UNAUTHORIZED',
statusCode: 401,
});
}
}
class RateLimitError extends AppError {
constructor(retryAfterSeconds) {
super('Too many requests', {
code: 'RATE_LIMITED',
statusCode: 429,
details: { retryAfter: retryAfterSeconds },
});
}
}
class ExternalServiceError extends AppError {
constructor(service, originalError) {
super(`${service} service unavailable`, {
code: `${service.toUpperCase()}_SERVICE_ERROR`,
statusCode: 502,
cause: originalError,
});
}
}
Centralized Error Handler
// middleware/errorHandler.js — Global Express error handler
const logger = require('./logger');
function errorHandler(err, req, res, _next) {
// Assign a unique ID to this error incident
const incidentId = crypto.randomUUID();
// Default values for unknown errors
const statusCode = err.statusCode || 500;
const code = err.code || 'INTERNAL_ERROR';
// Log EVERY error with full context
logger.error(`[Error ${incidentId}]`, {
incidentId,
code,
message: err.message,
stack: err.stack,
statusCode,
method: req.method,
url: req.url,
requestId: req.id,
userId: req.user?.id,
ip: req.ip,
userAgent: req.get('user-agent'),
body: req.method !== 'GET' ? sanitizeBody(req.body) : undefined,
cause: err.cause?.message,
});
// Don't expose internal details in production
const isProduction = process.env.NODE_ENV === 'production';
const response = {
error: {
code,
message: isProduction ? 'An internal error occurred' : err.message,
...(isProduction ? {} : { details: err.details }),
incidentId, // Include so support can look it up!
}
};
res.status(statusCode).json(response);
}
// Body sanitizer (prevent logging passwords/tokens)
function sanitizeBody(body) {
if (!body || typeof body !== 'object') return body;
const sensitiveFields = ['password', 'token', 'secret', 'apiKey', 'api_key', 'creditCard'];
const sanitized = { ...body };
for (const field of sensitiveFields) {
if (field in sanitized) sanitized[field] = '[REDACTED]';
}
return sanitized;
}
module.exports = errorHandler;
// In app.js (MUST be last middleware, after all routes):
app.use(errorHandler);
Async Error Handling Patterns
// Pattern 1: Async handler wrapper (eliminates try/catch boilerplate)
// utils/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
// Usage:
const getUser = asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User', req.params.id);
res.json({ data: user });
});
// Any thrown error automatically goes to errorHandler middleware!
// Pattern 2: Retry logic for transient failures
async function retry(fn, options = {}) {
const {
maxAttempts = 3,
delay = 1000, // Initial delay (ms)
maxDelay = 30000, // Max delay cap
backoff = 2, // Multiplier
retryableErrors = ['ECONNREFUSED', 'ETIMEDOUT', 'ECONNRESET', '502', '503', '504'],
} = options;
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
const shouldRetry = retryableErrors.some(code =>
err.message?.includes(code) ||
err.code === code ||
err.statusCode?.toString() === code
);
if (!shouldRetry || attempt === maxAttempts) throw err;
const waitTime = Math.min(delay * Math.pow(backoff, attempt - 1), maxDelay);
logger.warn(`Retry ${attempt}/${maxAttempts} for ${err.message} in ${waitTime}ms`);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
}
}
// Usage:
const data = await retry(() => fetchFromExternalAPI(id), {
maxAttempts: 5,
delay: 500,
});
// Pattern 3: Timeout wrapper
function withTimeout(promise, ms, errorMessage = 'Operation timed out') {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new AppError(errorMessage, { code: 'TIMEOUT', statusCode: 504 })), ms)
),
]);
}
// Usage:
const user = await withTimeout(
db.users.findById(userId),
5000, // 5 second timeout
'Database query timed out'
);
// Pattern 4: Circuit breaker (prevent cascading failures)
class CircuitBreaker {
constructor(name, options = {}) {
this.name = name;
this.failureThreshold = options.failureThreshold ?? 5;
this.resetTimeout = options.resetTimeout ?? 60000; // 1 minute
this.state = 'CLOSED'; // CLOSED (normal), OPEN (failing), HALF_OPEN (testing)
this.failures = 0;
this.lastFailureTime = null;
}
async execute(fn) {
if (this.state === 'OPEN') {
if (Date.now() - this.lastFailureTime > this.resetTimeout) {
this.state = 'HALF_OPEN';
} else {
throw new AppError(`${this.name} circuit is open`, {
code: 'CIRCUIT_OPEN',
statusCode: 503,
});
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (err) {
this.onFailure();
throw err;
}
}
onSuccess() {
this.failures = 0;
this.state = 'CLOSED';
}
onFailure() {
this.failures++;
this.lastFailureTime = Date.now();
if (this.failures >= this.failureThreshold) {
this.state = 'OPEN';
logger.warn(`Circuit breaker "${this.name}" OPENED after ${this.failures} failures`);
}
}
}
// Usage:
const apiBreaker = new CircuitBreaker('ExternalAPI');
const result = await apiBreaker.execute(() => fetchFromAPI(data));
Graceful Degradation Strategies
// When something fails, provide a reduced experience instead of crashing:
// Strategy 1: Fallback value
async function getUserPreferences(userId) {
try {
return await preferencesService.get(userId);
} catch (err) {
logger.warn(`Prefs service failed for ${userId}, using defaults`);
return { theme: 'light', language: 'en' }; // Sensible defaults
}
}
// Strategy 2: Cached stale data
async function getProductCatalog() {
try {
const fresh = await catalogService.fetch();
await cache.set('catalog', fresh, { ttl: 3600 });
return fresh;
} catch (err) {
logger.error('Catalog fetch failed, trying cache');
const cached = await cache.get('catalog');
if (cached) {
res.setHeader('X-Cache-Status', 'stale');
return cached;
}
throw new AppError('Catalog unavailable', { code: 'CATALOG_ERROR', statusCode: 503 });
}
}
// Strategy 3: Queue for later processing
async function sendEmail(to, subject, body) {
try {
await emailService.send({ to, subject, body });
} catch (err) {
logger.error(`Email send failed, queuing: ${err.message}`);
await emailQueue.add({ to, subject, body }); // Retry later via background worker
// Still return success to user (email will be sent eventually)
}
}
// Strategy 4: Feature flag off
async function renderDashboard(req, res) {
let analyticsData = {};
try {
analyticsData = await heavyAnalyticsQuery(req.user.id);
} catch (err) {
if (featureFlags.get('dashboard_analytics_optional')) {
logger.warn('Analytics failed, rendering without them');
analyticsData = {}; // Dashboard still works, just no charts
} else {
throw err; // Analytics are critical, fail the request
}
}
res.render('dashboard', { analyticsData });
}
Unhandled Rejection & Exception Protection
// These handlers should be at the TOP of your entry file (index.js):
// They catch errors that escape your normal error handling
// Unhandled promise rejections (Node.js 15+ doesn't crash by default, but you should log!)
process.on('unhandledRejection', (reason, promise) => {
logger.fatal('Unhandled rejection at:', promise, 'reason:', reason);
// In development: crash to fix the bug
if (process.env.NODE_ENV === 'development') {
process.exit(1);
}
// In production: log and continue (but investigate!)
});
// Uncaught exceptions (WILL crash Node.js — this is cleanup before death)
process.on('uncaughtException', (err) => {
logger.fatal('Uncaught exception:', err);
// Try to shutdown gracefully:
// 1. Notify monitoring system
// 2. Close database connections
// 3. Finish in-flight requests (if possible)
// Then exit:
process.exit(1); // Required: process must restart after uncaught exception
});
// Graceful shutdown on SIGTERM/SIGINT
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
async function gracefulShutdown(signal) {
logger.info(`${signal} received. Starting graceful shutdown...`);
// Stop accepting new connections
server.close(() => {
logger.info('HTTP server closed.');
});
// Close DB connections
await pool.end().catch(() => {});
await redis.quit().catch(() => {});
logger.info('Graceful shutdown complete.');
process.exit(0);
}
// Timeout force-kill (if graceful shutdown takes too long):
setTimeout(() => {
logger.error('Forced shutdown after timeout');
process.exit(1);
}, 10000).unref(); // Don't keep process alive just for this timer
What's your favorite error handling pattern? What's the worst bug caused by poor error handling that you've seen?
Follow @armorbreak for more practical developer guides.
Top comments (0)