Skip to main content

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:

Error handling middleware signature
app.use((err, req, res, next) => {
// Error handling logic
res.status(500).json({ message: 'An error occurred' });
});
Order Matters

Error-handling middleware must be defined after all other app.use() and route handlers. Express skips regular middleware when matching routes.

Basic Error Handler

Basic error handling middleware
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:

Custom error classes
// 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

Using custom error classes
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:

Async error wrapper
// 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:

Reusable async middleware
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:

Standard error response format
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

Handling specific 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:

Error logging with logging service
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

Complete 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

  1. Always use async error wrapper for async route handlers
  2. Create custom error classes for different error types
  3. Log errors with context (user, request path, timestamp)
  4. Use appropriate HTTP status codes (400, 401, 403, 404, 500)
  5. Don't expose sensitive information in error messages
  6. Differentiate development and production error responses
  7. Validate input early to catch errors before they propagate
  8. 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