Error Handling in Node.js: Beyond Try/Catch (2026)
Good error handling isn't about catching errors — it's about handling them gracefully so your app stays running.
The Philosophy
Bad error handling:
→ try/catch everything silently → bugs disappear, no one knows why
→ console.error(err) and move on → logs nobody reads
→ process.exit(1) on any error → crashes production
→ Return null/undefined → caller doesn't know WHY it failed
Good error handling:
→ Every error has a code and context → debugging is fast
→ Errors are categorized → different types get different treatment
→ Recovery is automatic where possible → self-healing apps
→ Users see helpful messages → not stack traces or "something went wrong"
Custom Error Classes
// Base application error
class AppError extends Error {
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
super(message);
this.name = 'AppError';
this.statusCode = statusCode;
this.code = code;
this.timestamp = new Date().toISOString();
// Capture stack trace (excluding constructor call)
Error.captureStackTrace(this, this.constructor);
}
toJSON() {
return {
error: {
code: this.code,
message: this.message,
statusCode: this.statusCode,
timestamp: this.timestamp,
}
};
}
}
// Domain-specific errors (inherit from AppError)
class NotFoundError extends AppError {
constructor(resource, id) {
super(`${resource} with ID '${id}' not found`, 404, 'NOT_FOUND');
this.resource = resource;
this.id = id;
}
}
class ValidationError extends AppError {
constructor(details) {
const message = Array.isArray(details)
? `${details.length} validation error(s)`
: 'Validation failed';
super(message, 422, 'VALIDATION_ERROR');
this.details = details; // Array of { field, issue }
}
}
class ConflictError extends AppError {
constructor(message) {
super(message || 'Resource conflict', 409, 'CONFLICT');
}
}
class RateLimitError extends AppError {
constructor(retryAfterSeconds) {
super('Too many requests', 429, 'RATE_LIMITED');
this.retryAfter = retryAfterSeconds;
}
}
class AuthenticationError extends AppError {
constructor(message) {
super(message || 'Authentication required', 401, 'UNAUTHORIZED');
}
}
class ForbiddenError extends AppError {
constructor(message) {
super(message || 'Access denied', 403, 'FORBIDDEN');
}
}
Centralized Error Handler (Express)
// middleware/errorHandler.js
function errorHandler(err, req, res, _next) {
// Request ID for tracing
const requestId = req.headers['x-request-id'] || 'unknown';
// Log the error (structured logging!)
const logData = {
requestId,
path: req.path,
method: req.method,
error: {
name: err.name,
message: err.message,
code: err.code,
stack: err.stack,
},
userId: req.user?.id,
body: sanitizeBody(req.body),
};
if (err.statusCode >= 500) {
// Server errors: full log for debugging
console.error(JSON.stringify(logData));
} else {
// Client errors: info level (expected, not a bug)
console.info(JSON.stringify(logData));
}
// Handle known error types
if (err instanceof AppError) {
const response = { ...err.toJSON(), requestId };
if (err instanceof RateLimitError) {
res.setHeader('Retry-After', err.retryAfter);
}
return res.status(err.statusCode).json(response);
}
// Handle specific library errors
if (err.code === 'SQLITE_CONSTRAINT') {
return res.status(409).json({
error: { code: 'DUPLICATE', message: 'Record already exists', requestId },
});
}
if (err.code === 'ECONNREFUSED') {
return res.status(503).json({
error: { code: 'SERVICE_UNAVAILABLE', message: 'Service temporarily unavailable', requestId },
});
}
// Unknown/unhandled errors: safe generic response
console.error(`[UNHANDLED] ${requestId}: ${err.stack}`);
const isProduction = process.env.NODE_ENV === 'production';
return res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: isProduction ? 'An internal error occurred' : err.message,
requestId,
...(isProduction ? {} : { stack: err.stack }),
}
});
}
// Async handler wrapper (eliminates try/catch boilerplate)
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
// Usage:
app.get('/api/users/:id',
asyncHandler(async (req, res) => {
const user = await userService.findById(req.params.id);
if (!user) throw new NotFoundError('User', req.params.id);
res.json({ success: true, data: user });
})
);
app.use(errorHandler); // Must be last!
Graceful Degradation Patterns
// Pattern 1: Fallback chain (try multiple sources)
async function getConfig() {
const sources = [
() => fetchFromRedis(),
() => fetchFromDatabase(),
() => fetchFromCacheFile(),
() => ({ /* hardcoded defaults */ }),
];
for (const source of sources) {
try {
const config = await source();
if (config && Object.keys(config).length > 0) return config;
} catch (err) {
console.warn(`Config source failed: ${err.message}`);
continue;
}
}
throw new Error('All config sources failed');
}
// Pattern 2: Circuit breaker (stop hammering failing services)
class CircuitBreaker {
constructor(fn, options = {}) {
this.fn = fn;
this.failureThreshold = options.failureThreshold ?? 5;
this.resetTimeout = options.resetTimeout ?? 30000; // 30s
this.state = 'CLOSED'; // CLOSED | OPEN | HALF_OPEN
this.failures = 0;
this.lastFailureTime = null;
}
async execute(...args) {
if (this.state === 'OPEN') {
if (Date.now() - this.lastFailureTime >= this.resetTimeout) {
this.state = 'HALF_OPEN'; // Try one request
} else {
throw new Error('Circuit breaker OPEN - service unavailable');
}
}
try {
const result = await this.fn(...args);
this.onSuccess();
return result;
} catch (err) {
this.onFailure(err);
throw err;
}
}
onSuccess() {
this.failures = 0;
this.state = 'CLOSED';
}
onFailure(err) {
this.failures++;
this.lastFailureTime = Date.now();
if (this.failures >= this.failureThreshold) {
this.state = 'OPEN';
console.warn(`Circuit breaker OPEN after ${this.failures} failures`);
}
}
}
const apiBreaker = new CircuitBreaker(() => externalAPI.call());
await apiBreaker.execute(); // Auto-fails fast when service is down
// Pattern 3: Retry with backoff
async function retryWithBackoff(fn, options = {}) {
const {
maxAttempts = 3,
baseDelay = 1000,
maxDelay = 10000,
shouldRetry = (err) => !['4xx'].includes(err.statusCode),
} = options;
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (!shouldRetry(err) || attempt === maxAttempts) throw err;
const delay = Math.min(baseDelay * 2 ** (attempt - 1), maxDelay);
const jitter = delay * (0.5 + Math.random() * 0.5); // Add jitter
console.warn(`Attempt ${attempt}/${maxAttempts} failed, retrying in ${Math.round(jitter)}ms`);
await sleep(jitter);
}
}
throw lastError;
}
// Usage:
const data = await retryWithBackoff(
() => fetchFromUnstableAPI(),
{ maxAttempts: 5, baseDelay: 1000, shouldRetry: (e) => e.code !== 'NOT_FOUND' }
);
Unhandled Rejection & Uncaught Exception
// These MUST be set up at the top of your entry file
// They are your last line of defense
process.on('unhandledRejection', (reason, promise) => {
console.error('[UNHANDLED REJECTION]', reason);
// Don't crash! Log it and decide based on severity
// In development: crash loudly
if (process.env.NODE_ENV === 'development') {
throw reason; // Crashes with full stack trace
}
// In production: log and continue (or gracefully shutdown)
});
process.on('uncaughtException', (error) => {
console.error('[UNCAUGHT EXCEPTION]', error);
// Best practice: shutdown gracefully
// The process may be in an undefined state
setTimeout(() => {
process.exit(1); // Force exit if graceful shutdown takes too long
}, 1000).unref(); // Don't keep process alive just for this
// Cleanup before exit:
// - Close database connections
// - Flush logs
// - Notify monitoring service
server.close(() => {
console.log('Server closed due to uncaught exception');
process.exit(1);
});
});
// Graceful shutdown handler
function setupGracefulShutdown(server) {
const shutdown = (signal) => {
console.log(`${signal} received, shutting down gracefully...`);
// Stop accepting new connections
server.close(() => {
console.log('HTTP server closed');
process.exit(0);
});
// Force exit after timeout
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 30000).unref();
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
}
Structured Logging
// logger.js — simple structured logger
const levels = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
const currentLevel = levels[process.env.LOG_LEVEL] || levels.INFO;
function log(level, message, meta = {}) {
if (levels[level] < currentLevel) return;
const entry = {
timestamp: new Date().toISOString(),
level,
message,
...meta,
pid: process.pid,
hostname: require('os').hostname(),
};
const line = JSON.stringify(entry);
switch (level) {
case 'ERROR': case 'WARN': console.error(line); break;
default: console.log(line);
}
}
export const logger = {
debug: (msg, meta) => log('DEBUG', msg, meta),
info: (msg, meta) => log('INFO', msg, meta),
warn: (msg, meta) => log('WARN', msg, meta),
error: (msg, meta) => log('ERROR', msg, meta),
};
// Usage in error handlers:
logger.error('Database connection failed', {
host: dbHost,
port: dbPort,
errorCode: err.code,
retryCount: attempts,
});
// Output: {"timestamp":"...","level":"ERROR","message":"Database connection failed","host":"db.example.com","port":5432,"errorCode":"ECONNREFUSED","retryCount":3,...}
// This is parseable by log aggregation tools (Datadog, CloudWatch, etc.)
Quick Checklist
Before shipping error handling:
□ Custom error classes with status codes and machine-readable codes?
□ Global error handler catches ALL unhandled errors?
□ Async errors wrapped (asyncHandler or equivalent)?
□ No raw stack traces exposed to users in production?
□ Every error logged with enough context to debug?
□ Request IDs included in all error responses?
□ Circuit breaker or rate limiting on external calls?
□ Retry logic for transient failures?
□ Graceful shutdown on SIGTERM/SIGINT?
□ UnhandledRejection listener registered?
□ UncaughtException listener registered?
□ Error responses follow consistent JSON format?
□ Client errors (4xx) vs server errors (5xx) handled differently?
Score yourself: Each ☑️ is a production-ready practice.
What's the worst production error you've debugged? How did you find it?
Follow @armorbreak for more practical developer guides.
Top comments (0)