Error Handling in Node.js: Beyond Try/Catch (2026)
Most developers stop at try/catch. Here's the complete error handling system I use in production.
The Problem with Basic Error Handling
// ❌ This is what most code looks like
app.get('/api/users/:id', async (req, res) => {
try {
const user = await User.findById(req.params.id);
res.json(user);
} catch (err) {
console.error(err); // Lost in a sea of logs
res.status(500).json({ error: 'Something went wrong' }); // Useless message
}
});
// Problems:
// 1. No error classification — everything is "something went wrong"
// 2. No context — which user? Which request?
// 3. No recovery strategy — can the client retry?
// 4. Leaks internals in development, hides them in production inconsistently
Step 1: Custom Error Classes
// src/errors/AppError.js
class AppError extends Error {
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.code = code; // Machine-readable error code
this.isOperational = true; // Distinguishes from programming errors
Error.captureStackTrace(this, this.constructor);
}
toJSON() {
return {
error: {
code: this.code,
message: this.message,
...(this.details && { details: this.details }),
...(this.field && { field: this.field }),
}
};
}
}
// Predefined error types
class NotFoundError extends AppError {
constructor(resource = 'Resource', id) {
super(`${resource} not found${id ? ` (id: ${id})` : ''}`, 404, 'NOT_FOUND');
this.resource = resource;
}
}
class ValidationError extends AppError {
constructor(details = []) {
super('Validation failed', 422, 'VALIDATION_ERROR');
this.details = details; // Array of { field, issue }
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401, 'UNAUTHORIZED');
}
}
class ForbiddenError extends AppError {
constructor(message = 'Access denied') {
super(message, 403, 'FORBIDDEN');
}
}
class ConflictError extends AppError {
constructor(resource, field) {
super(
`${resource} already exists`,
409,
'CONFLICT'
);
this.resource = resource;
this.field = field;
}
}
class RateLimitError extends AppError {
constructor(retryAfterSeconds = 60) {
super('Too many requests', 429, 'RATE_LIMITED');
this.retryAfter = retryAfterSeconds;
}
}
module.exports = {
AppError,
NotFoundError,
ValidationError,
UnauthorizedError,
ForbiddenError,
ConflictError,
RateLimitError,
};
Step 2: Centralized Error Handler Middleware
// src/middleware/errorHandler.js
const { AppError } = require('../errors');
function errorHandler(err, req, res, _next) {
const requestId = req.id || req.headers['x-request-id'];
// Default values for unexpected errors
let statusCode = err.statusCode || 500;
let errorCode = err.code || 'INTERNAL_ERROR';
let message = err.message || 'An internal error occurred';
// Log ALL errors with context
const logData = {
requestId,
method: req.method,
path: req.path,
ip: req.ip,
userId: req.user?.id,
errorName: err.name,
errorMessage: message,
errorCode,
statusCode,
stack: err.stack,
};
if (err.isOperational) {
// Expected business logic error → info level
console.info(JSON.stringify(logData));
} else {
// Unexpected/programming error → error + alert
console.error(JSON.stringify(logData));
// In production, don't leak internals
if (process.env.NODE_ENV === 'production') {
statusCode = 500;
errorCode = 'INTERNAL_ERROR';
message = 'An internal error occurred';
// TODO: Send to Sentry/DataDog/etc here
// captureException(err, { requestId });
}
}
// Build response
const response = {
error: {
code: errorCode,
message,
},
};
// Include request ID for support lookup
if (requestId) {
response.requestId = requestId;
}
// Include retry info for rate limits / service unavailable
if (err.retryAfter) {
response.retryAfter = err.retryAfter;
}
// Include details for validation errors
if (err.details) {
response.error.details = err.details;
}
// Send response
res.status(statusCode).json(response);
}
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection:', JSON.stringify({
reason: reason?.message || String(reason),
stack: reason?.stack,
}));
// Don't crash in production, but do in test
if (process.env.NODE_ENV === 'test') {
throw reason;
}
});
// Handle uncaught exceptions (last resort)
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', JSON.stringify({
message: err.message,
stack: err.stack,
}));
// Unrecoverable — must exit
// Use a graceful shutdown instead of process.exit(1)
gracefulShutdown('UNCAUGHT_EXCEPTION');
});
module.exports = errorHandler;
Step 3: Using It in Controllers
// src/controllers/userController.js
const { NotFoundError, ValidationError, ConflictError } = require('../errors');
const userService = require('../services/userService');
exports.getUser = async (req, res, next) => {
try {
const user = await userService.findById(req.params.id);
if (!user) throw new NotFoundError('User', req.params.id);
res.json({ data: user });
} catch (err) {
next(err); // Pass to centralized handler
}
};
exports.createUser = async (req, res, next) => {
try {
const { email, name, password } = req.body;
// Validation
const errors = [];
if (!email) errors.push({ field: 'email', issue: 'Email is required' });
if (!name) errors.push({ field: 'name', issue: 'Name is required' });
if (!password) errors.push({ field: 'password', issue: 'Password is required' });
else if (password.length < 8) errors.push({ field: 'password', issue: 'Must be 8+ characters' });
if (errors.length > 0) throw new ValidationError(errors);
// Business rule check
const existing = await userService.findByEmail(email);
if (existing) throw new ConflictError('User', 'email');
const user = await userService.create({ email, name, password });
res.status(201).json({ data: user });
} catch (err) {
next(err);
}
};
Step 4: Async Wrapper (Eliminates Try/Catch Boilerplate)
// src/middleware/asyncHandler.js
// Wraps async route handlers to forward errors to Express error middleware
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
module.exports = asyncHandler;
Now your controllers become clean:
// BEFORE (verbose):
exports.getUser = async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User');
res.json(user);
} catch (err) {
next(err);
}
};
// AFTER (clean):
exports.getUser = asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User');
res.json(user);
});
Step 5: Input Validation Middleware
// src/middleware/validate.js
const { ValidationError } = require('../errors');
// Validation rules schema
function validate(rules) {
return (req, res, next) => {
const errors = [];
const source = { ...req.body, ...req.query, ...req.params };
for (const [field, fieldRules] of Object.entries(rules)) {
const value = source[field];
for (const rule of fieldRules) {
if (rule.required && (value === undefined || value === null)) {
errors.push({ field, issue: `${field} is required` });
break; // Skip other rules for missing fields
}
if (value === undefined || value === null) continue; // Optional field not provided
if (rule.type === 'string' && typeof value !== 'string') {
errors.push({ field, issue: `Must be a string` });
} else if (rule.type === 'number' && typeof value !== 'number') {
errors.push({ field, issue: `Must be a number` });
} else if (rule.type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
errors.push({ field, issue: `Invalid email format` });
} else if (rule.minLength && typeof value === 'string' && value.length < rule.minLength) {
errors.push({ field, issue: `Must be at least ${rule.minLength} characters` });
} else if (rule.maxLength && typeof value === 'string' && value.length > rule.maxLength) {
errors.push({ field, issue: `Must be no more than ${rule.maxLength} characters` });
} else if (rule.min !== undefined && typeof value === 'number' && value < rule.min) {
errors.push({ field, issue: `Must be >= ${rule.min}` });
} else if (rule.max !== undefined && typeof value === 'number' && value > rule.max) {
errors.push({ field, issue: `Must be <= ${rule.max}` });
} else if (rule.enum && !rule.enum.includes(value)) {
errors.push({ field, issue: `Must be one of: ${rule.enum.join(', ')}` });
} else if (rule.pattern && !rule.pattern.test(value)) {
errors.push({ field, issue: `Invalid format` });
} else if (rule.custom && typeof rule.custom === 'function') {
const customError = rule.custom(value, source);
if (customError) errors.push({ field, issue: customError });
}
}
}
if (errors.length > 0) {
return next(new ValidationError(errors));
}
next();
};
}
// Usage:
app.post('/api/users',
validate({
email: [
{ type: 'email', required: true },
{ maxLength: 255 },
],
name: [
{ type: 'string', required: true },
{ minLength: 2 },
{ maxLength: 100 },
],
password: [
{ type: 'string', required: true },
{ minLength: 8 },
{ pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, message: 'Must contain uppercase, lowercase, and number' },
],
role: [
{ enum: ['user', 'admin'] }, // optional, defaults if not sent
],
}),
userController.createUser
);
Step 6: API Response Standardization
// src/utils/response.js
class ApiResponse {
static success(res, data, meta = {}) {
return res.status(200).json({
success: true,
data,
...meta,
});
}
static created(res, data) {
return res.status(201).json({
success: true,
data,
});
}
static noContent(res) {
return res.status(204).send();
}
static paginated(res, items, page, limit, total) {
return res.status(200).json({
success: true,
data: items,
pagination: {
page: Number(page),
limit: Number(limit),
total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1,
},
});
}
static error(res, error) {
return res.status(error.statusCode || 500).json(error.toJSON());
}
}
module.exports = ApiResponse;
What the Client Sees
Success Response
{
"success": true,
"data": {
"id": "usr_abc123",
"email": "user@example.com",
"name": "John Doe"
}
}
Paginated Response
{
"success": true,
"data": [...],
"pagination": {
"page": 2,
"limit": 20,
"total": 150,
"totalPages": 8,
"hasNext": true,
"hasPrev": true
}
}
Error Responses
// Not Found
{ "error": { "code": "NOT_FOUND", "message": "User not found (id: xyz)" } }
// Validation Error
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [
{ "field": "email", "issue": "Invalid email format" },
{ "field": "password", "issue": "Must be 8+ characters" }
]
}
}
// Internal Error (with request ID for support)
{
"error": { "code": "INTERNAL_ERROR", "message": "An internal error occurred" },
"requestId": "req_abc123"
}
Graceful Shutdown
let isShuttingDown = false;
function gracefulShutdown(reason = 'SIGTERM') {
if (isShuttingDown) return;
isShuttingDown = true;
console.log(`\n[${reason}] Starting graceful shutdown...`);
// Stop accepting new connections
server.close(() => {
console.log('[shutdown] HTTP server closed');
process.exit(0);
});
// Force exit after timeout (e.g., 10 seconds)
setTimeout(() => {
console.error('[shutdown] Forced exit after timeout');
process.exit(1);
}, 10_000);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
Testing Your Error Handling
// tests/unit/errors.test.js
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { NotFoundError, ValidationError, AppError } from '../src/errors/index.js';
describe('NotFoundError', () => {
it('should have correct status code and message', () => {
const err = new NotFoundError('User', '123');
assert.equal(err.statusCode, 404);
assert.equal(err.code, 'NOT_FOUND');
assert.equal(err.message, 'User not found (id: 123)');
assert.equal(err.isOperational, true);
});
it('should serialize to JSON correctly', () => {
const err = new NotFoundError('Post');
const json = err.toJSON();
assert.equal(json.error.code, 'NOT_FOUND');
assert.ok(json.error.message.includes('Post'));
});
});
describe('ValidationError', () => {
it('should include details array', () => {
const err = new ValidationError([
{ field: 'email', issue: 'Required' },
{ field: 'name', issue: 'Required' },
]);
assert.equal(err.statusCode, 422);
assert.equal(err.details.length, 2);
});
});
How do you handle errors? Still using basic try/catch or have you built something more robust?
Follow @armorbreak for more practical Node.js guides.
Top comments (0)