Error Handling and Error Middleware
Overview
Error handling is critical for building robust, production-ready Express applications. Proper error handling ensures your app gracefully handles unexpected situations, logs errors for debugging, and sends appropriate responses to clients.
Error Handling Middleware
Express uses middleware with four parameters to handle errors. This special signature tells Express it's an error handler:
app.use((err, req, res, next) => {
// Error handling logic
res.status(500).json({ message: 'An error occurred' });
});
Error-handling middleware must be defined after all other app.use() and route handlers. Express skips regular middleware when matching routes.
Basic Error Handler
const express = require('express');
const app = express();
// Regular middleware and routes...
app.get('/users/:id', (req, res) => {
const id = req.params.id;
if (!id) {
throw new Error('User ID is required');
}
res.json({ id, name: 'John' });
});
// Error handling middleware (4 parameters!)
app.use((err, req, res, next) => {
console.error(err.stack);
const status = err.status || 500;
const message = err.message || 'Internal Server Error';
res.status(status).json({
success: false,
status,
message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
});
});
app.listen(3000, () => console.log('Server running'));
Custom Error Classes
Create custom error classes for different error types:
// Base error class
class AppError extends Error {
constructor(message, status = 500) {
super(message);
this.status = status;
Error.captureStackTrace(this, this.constructor);
}
}
// Specific error types
class ValidationError extends AppError {
constructor(message) {
super(message, 400);
this.name = 'ValidationError';
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404);
this.name = 'NotFoundError';
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401);
this.name = 'UnauthorizedError';
}
}
class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 403);
this.name = 'ForbiddenError';
}
}
module.exports = {
AppError,
ValidationError,
NotFoundError,
UnauthorizedError,
ForbiddenError
};
Using Custom Errors
const express = require('express');
const {
ValidationError,
NotFoundError,
UnauthorizedError
} = require('./errors');
const app = express();
app.use(express.json());
// Simulated database
const users = [
{ id: 1, name: 'John', email: 'john@example.com' },
{ id: 2, name: 'Jane', email: 'jane@example.com' }
];
// Route with error handling
app.get('/users/:id', (req, res, next) => {
const { id } = req.params;
// Validate input
if (!id || isNaN(id)) {
return next(new ValidationError('User ID must be a number'));
}
// Find user
const user = users.find(u => u.id === parseInt(id));
if (!user) {
return next(new NotFoundError('User'));
}
res.json(user);
});
// Create user endpoint
app.post('/users', (req, res, next) => {
const { name, email } = req.body;
// Validation
if (!name || !email) {
return next(new ValidationError('Name and email are required'));
}
if (!email.includes('@')) {
return next(new ValidationError('Invalid email format'));
}
// Simulated save
const newUser = { id: users.length + 1, name, email };
users.push(newUser);
res.status(201).json(newUser);
});
// Error handler
app.use((err, req, res, next) => {
console.error(err);
const status = err.status || 500;
const message = err.message || 'Internal Server Error';
res.status(status).json({
success: false,
error: {
status,
message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
}
});
});
// 404 handler (must be after all routes)
app.use((req, res) => {
res.status(404).json({
success: false,
error: {
status: 404,
message: 'Route not found'
}
});
});
app.listen(3000);
Async Error Wrapper
Wrapping async functions prevents uncaught promise rejections:
// Wrapper function to catch async errors
const asyncHandler = (fn) => {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
// Usage example
const fetchUser = asyncHandler(async (req, res, next) => {
const { id } = req.params;
if (!id) {
throw new ValidationError('ID is required');
}
// Simulated async operation
const user = await getUser(id); // This will be caught if it rejects
if (!user) {
throw new NotFoundError('User');
}
res.json(user);
});
app.get('/users/:id', fetchUser);
Async Wrapper Middleware
Create reusable middleware for common patterns:
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
const validateId = asyncHandler(async (req, res, next) => {
const { id } = req.params;
if (!id || isNaN(id)) {
throw new ValidationError('Invalid ID format');
}
next();
});
const validateBody = (schema) => {
return (req, res, next) => {
const { error } = schema.validate(req.body);
if (error) {
return next(new ValidationError(error.details[0].message));
}
next();
};
};
// Usage in routes
app.get('/users/:id', validateId, asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user);
}));
app.post('/users', validateBody(userSchema), asyncHandler(async (req, res) => {
const user = await User.create(req.body);
res.status(201).json(user);
}));
Standard Error Response Format
Maintain consistency across all error responses:
const sendErrorResponse = (req, res, error) => {
const status = error.status || 500;
const isDevelopment = process.env.NODE_ENV === 'development';
res.status(status).json({
success: false,
error: {
status,
message: error.message,
code: error.code || null,
...(isDevelopment && { details: error.details }),
...(isDevelopment && { stack: error.stack })
},
timestamp: new Date().toISOString(),
path: req.originalUrl
});
};
// Global error handler
app.use((err, req, res, next) => {
sendErrorResponse(req, res, err);
});
Handling Different Error Types
app.use((err, req, res, next) => {
// Mongoose validation error
if (err.name === 'ValidationError') {
return res.status(400).json({
success: false,
error: {
status: 400,
message: 'Validation failed',
details: Object.values(err.errors).map(e => e.message)
}
});
}
// Mongoose cast error
if (err.name === 'CastError') {
return res.status(400).json({
success: false,
error: {
status: 400,
message: `Invalid ${err.path}: ${err.value}`
}
});
}
// JWT errors
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({
success: false,
error: {
status: 401,
message: 'Invalid token'
}
});
}
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
error: {
status: 401,
message: 'Token expired'
}
});
}
// Default error
res.status(err.status || 500).json({
success: false,
error: {
status: err.status || 500,
message: err.message || 'Internal Server Error'
}
});
});
Logging Errors
Integrate logging with error handling:
const logger = {
error: (message, error, metadata = {}) => {
console.error(`[ERROR] ${message}`, {
message: error.message,
status: error.status,
stack: error.stack,
...metadata
});
},
warn: (message, metadata = {}) => {
console.warn(`[WARN] ${message}`, metadata);
},
info: (message, metadata = {}) => {
console.log(`[INFO] ${message}`, metadata);
}
};
// Using in error handler
app.use((err, req, res, next) => {
const status = err.status || 500;
logger.error('Request failed', err, {
method: req.method,
path: req.path,
ip: req.ip,
userId: req.user?.id
});
res.status(status).json({
success: false,
error: {
status,
message: err.message
}
});
});
Practical Error Handling Example
const express = require('express');
const app = express();
app.use(express.json());
// Custom errors
class AppError extends Error {
constructor(message, status = 500) {
super(message);
this.status = status;
}
}
// Async wrapper
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// Simulated data
const posts = [
{ id: 1, title: 'First Post', content: 'Content 1' },
{ id: 2, title: 'Second Post', content: 'Content 2' }
];
// Routes
app.get('/posts/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
if (!id || isNaN(id)) {
throw new AppError('Invalid post ID', 400);
}
const post = posts.find(p => p.id === parseInt(id));
if (!post) {
throw new AppError('Post not found', 404);
}
res.json(post);
}));
app.post('/posts', asyncHandler(async (req, res) => {
const { title, content } = req.body;
if (!title || !content) {
throw new AppError('Title and content are required', 400);
}
const newPost = {
id: Math.max(...posts.map(p => p.id)) + 1,
title,
content
};
posts.push(newPost);
res.status(201).json(newPost);
}));
// Global error handler
app.use((err, req, res, next) => {
const status = err.status || 500;
const message = err.message || 'Internal Server Error';
console.error(`[${status}] ${message}`);
res.status(status).json({
success: false,
error: {
status,
message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
}
});
});
// 404 handler
app.use((req, res) => {
res.status(404).json({
success: false,
error: {
status: 404,
message: 'Route not found'
}
});
});
app.listen(3000, () => console.log('Server running on port 3000'));
Best Practices
- Always use async error wrapper for async route handlers
- Create custom error classes for different error types
- Log errors with context (user, request path, timestamp)
- Use appropriate HTTP status codes (400, 401, 403, 404, 500)
- Don't expose sensitive information in error messages
- Differentiate development and production error responses
- Validate input early to catch errors before they propagate
- Test error scenarios as thoroughly as successful paths
Key Takeaways
- Error-handling middleware has 4 parameters:
(err, req, res, next) - Must be defined after all other middleware and routes
- Use custom error classes to standardize error handling
- Wrap async functions to catch promise rejections
- Log errors with relevant context for debugging
- Send consistent, user-friendly error responses