Error Handling Patterns
Catch, handle, and recover from errors gracefully - multiple approaches for robust APIs.
6 min read
Why Error Handling Matters#
Users don't see your code - they see your errors. Bad error handling means:
- Cryptic messages: "Something went wrong"
- Leaked internals: Stack traces in production
- Crashed servers: Unhandled exceptions
- Lost debugging info: Errors without context
Good error handling means happy users, easier debugging, and stable servers.
The Express Error Flow#
Request → Route → Controller → Service → Database
↓
Error thrown
↓
Error Middleware
↓
JSON Response
Custom Error Classes#
Create a hierarchy of errors:
javascript
// src/utils/errors.js
// Base error - all custom errors extend this
export class AppError extends Error {
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = true; // Expected error, not a bug
Error.captureStackTrace(this, this.constructor);
}
toJSON() {
return {
code: this.code,
message: this.message,
...(this.details && { details: this.details }),
};
}
}
// Client errors (4xx)
export class BadRequestError extends AppError {
constructor(message = 'Bad request') {
super(message, 400, 'BAD_REQUEST');
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401, 'UNAUTHORIZED');
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 403, 'FORBIDDEN');
}
}
export class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
export class ConflictError extends AppError {
constructor(message = 'Resource already exists') {
super(message, 409, 'CONFLICT');
}
}
export class ValidationError extends AppError {
constructor(message = 'Validation failed', details = []) {
super(message, 400, 'VALIDATION_ERROR');
this.details = details;
}
}
export class RateLimitError extends AppError {
constructor(message = 'Too many requests') {
super(message, 429, 'RATE_LIMIT_EXCEEDED');
}
}
// Server errors (5xx)
export class InternalError extends AppError {
constructor(message = 'Internal server error') {
super(message, 500, 'INTERNAL_ERROR');
}
}
export class ServiceUnavailableError extends AppError {
constructor(message = 'Service temporarily unavailable') {
super(message, 503, 'SERVICE_UNAVAILABLE');
}
}
Global Error Middleware#
Catch all errors in one place:
javascript
// src/middleware/error.js
import { logger } from '../utils/logger.js';
import { config } from '../config/index.js';
import { AppError } from '../utils/errors.js';
export function notFoundHandler(req, res, next) {
res.status(404).json({
error: {
code: 'NOT_FOUND',
message: `Cannot ${req.method} ${req.path}`,
},
});
}
export function errorHandler(err, req, res, next) {
// Log the error
logger.error({
err,
requestId: req.id,
method: req.method,
path: req.path,
userId: req.user?.id,
});
// Operational errors (expected)
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: err.toJSON(),
...(req.id && { requestId: req.id }),
});
}
// Mongoose validation error
if (err.name === 'ValidationError') {
const details = Object.values(err.errors).map((e) => ({
field: e.path,
message: e.message,
}));
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Validation failed',
details,
},
});
}
// Mongoose duplicate key
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
return res.status(409).json({
error: {
code: 'CONFLICT',
message: `${field} already exists`,
},
});
}
// Mongoose cast error (invalid ObjectId)
if (err.name === 'CastError') {
return res.status(400).json({
error: {
code: 'BAD_REQUEST',
message: `Invalid ${err.path}: ${err.value}`,
},
});
}
// JWT errors
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'Invalid token',
},
});
}
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
error: {
code: 'TOKEN_EXPIRED',
message: 'Token expired',
},
});
}
// Unknown errors - don't leak details in production
const message = config.env === 'production'
? 'Internal server error'
: err.message;
res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message,
...(config.env !== 'production' && { stack: err.stack }),
},
...(req.id && { requestId: req.id }),
});
}
Async Error Handling#
Option 1: express-async-errors#
bash
npm install express-async-errors
javascript
// Import at the top of app.js - that's it!
import 'express-async-errors';
// Now async errors are caught automatically
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User'); // Caught!
res.json({ data: user });
});
Option 2: Wrapper Function#
javascript
// src/utils/asyncHandler.js
export function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
// Usage
import { asyncHandler } from '../utils/asyncHandler.js';
router.get('/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User');
res.json({ data: user });
}));
Option 3: Try-Catch (Verbose)#
javascript
// Not recommended - too much boilerplate
router.get('/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User');
res.json({ data: user });
} catch (error) {
next(error);
}
});
Recommendation
Use express-async-errors - it's the cleanest solution. Just import once and forget about it.
Error Response Format#
Consistent format for all errors:
javascript
// Success response
{
"data": { ... }
}
// Error response
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [
{ "field": "email", "message": "Invalid email format" },
{ "field": "password", "message": "Too short" }
]
},
"requestId": "abc-123"
}
Using Errors in Controllers#
javascript
// src/controllers/users.js
import { NotFoundError, ForbiddenError } from '../utils/errors.js';
export async function getUser(req, res) {
const user = await userService.findById(req.params.id);
if (!user) {
throw new NotFoundError('User');
}
res.json({ data: user });
}
export async function updateUser(req, res) {
// Authorization check
if (req.user.id !== req.params.id && req.user.role !== 'admin') {
throw new ForbiddenError('Cannot update other users');
}
const user = await userService.update(req.params.id, req.body);
if (!user) {
throw new NotFoundError('User');
}
res.json({ data: user });
}
Using Errors in Services#
javascript
// src/services/auth.js
import { UnauthorizedError, ConflictError } from '../utils/errors.js';
export async function login(email, password) {
const user = await User.findOne({ email }).select('+password');
if (!user) {
throw new UnauthorizedError('Invalid credentials');
}
const isValid = await user.comparePassword(password);
if (!isValid) {
throw new UnauthorizedError('Invalid credentials');
}
return generateTokens(user);
}
export async function register(data) {
const existing = await User.findOne({ email: data.email });
if (existing) {
throw new ConflictError('Email already registered');
}
return User.create(data);
}
Complete Setup#
javascript
// src/app.js
import 'express-async-errors'; // Must be first!
import express from 'express';
import { errorHandler, notFoundHandler } from './middleware/error.js';
import routes from './routes/index.js';
const app = express();
app.use(express.json());
// Routes
app.use('/api', routes);
// Health check
app.get('/health', (req, res) => res.json({ status: 'ok' }));
// 404 handler - must be after all routes
app.use(notFoundHandler);
// Error handler - must be last
app.use(errorHandler);
export { app };
Key Takeaways#
- Use custom error classes - Consistent, informative errors
- Centralize error handling - One middleware catches all
- Use express-async-errors - No more try-catch everywhere
- Return consistent format - Same structure for all errors
- Don't leak details in production - Generic messages, log the details
The Pattern
Throw meaningful errors in your code, let middleware handle the response. Controllers and services stay clean, error handling stays consistent.
Continue Learning
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.