Introduction to Error Handling
Error handling is like setting up a safety net for your application. Just as a tightrope walker relies on a safety net to catch them if they fall, proper error handling ensures your application gracefully manages unexpected situations without crashing.
In the world of Express.js applications, things don't always go as planned. Users might request pages that don't exist, database connections might fail, or external APIs might be temporarily unavailable. Without proper error handling, these situations can lead to your application crashing or returning cryptic error messages that leave users confused and frustrated.
This tutorial will guide you through implementing robust error handling in Express applications, focusing on various types of 404 errors and other common error scenarios you'll encounter in production.
Fundamentals of HTTP Status Codes
Before diving into Express error handling, let's understand HTTP status codes. These codes are like a universal language between servers and clients, communicating what happened with a request. Think of them as traffic signals on the web:
- 200-299 (Success): Green light - Everything went well
- 300-399 (Redirection): Yellow light - More action needed to complete the request
- 400-499 (Client Errors): Red light - The client did something wrong
- 500-599 (Server Errors): Flashing red light - Something went wrong on the server
For error handling, we're primarily concerned with 400 and 500 series status codes.
Common Error Status Codes
// 404 - Not Found: The requested resource doesn't exist
res.status(404).send('Resource not found');
// 400 - Bad Request: The request was malformed
res.status(400).send('Bad request');
// 401 - Unauthorized: Authentication is required
res.status(401).send('Authentication required');
// 403 - Forbidden: Client doesn't have access rights
res.status(403).send('Access forbidden');
// 500 - Internal Server Error: Something went wrong on the server
res.status(500).send('Server error');
Express Error Handling Architecture
Express handles errors through middleware functions with a specific signature. Think of middleware as checkpoints along a conveyor belt in a factory. Regular middleware functions have access to the request and response objects, while error-handling middleware has an additional error parameter.
Error-Handling Middleware Structure
// Regular middleware - (req, res, next)
app.use((req, res, next) => {
// Process request
next(); // Pass control to the next middleware
});
// Error-handling middleware - (err, req, res, next)
app.use((err, req, res, next) => {
// Handle the error
res.status(500).send('Something broke!');
});
Error middleware is like the quality control station at the end of our factory conveyor belt. It catches any problems that weren't addressed by earlier stations.
Important: Error-handling middleware must be defined last, after all other app.use() and routes calls. This ensures it can catch errors from any previous middleware or routes.
Handling 404 Errors in Express
In Express applications, 404 errors occur in different scenarios. Understanding these scenarios is like knowing all the different ways someone might get lost in a city - each requires a slightly different approach to help them find their way.
Scenario One: Route Not Found
This is the most common 404 scenario - when a user tries to access a route that doesn't exist in your application.
// app.js or server.js
const express = require('express');
const app = express();
// Define your routes
app.get('/home', (req, res) => {
res.send('Welcome home!');
});
app.get('/about', (req, res) => {
res.send('About us page');
});
// Catch-all route for undefined routes
// This must be placed AFTER all defined routes
app.use((req, res) => {
res.status(404).send('Page not found');
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
The catch-all middleware acts like a safety net, catching any requests that weren't handled by your defined routes. It's like having a helpful "Information" desk in a mall that assists visitors who can't find a specific store.
Scenario Two: Resource Not Found
This occurs when a route exists, but the specific resource requested within that route doesn't exist (like requesting a product with an ID that's not in your database).
// routes/products.js
const express = require('express');
const router = express.Router();
// Simulated database
const products = [
{ id: 1, name: 'Laptop' },
{ id: 2, name: 'Phone' }
];
router.get('/:id', (req, res) => {
const productId = parseInt(req.params.id);
const product = products.find(p => p.id === productId);
if (!product) {
// Resource-specific 404
return res.status(404).send(`Product with ID ${productId} not found`);
}
res.json(product);
});
module.exports = router;
// In your main app.js
const productRoutes = require('./routes/products');
app.use('/products', productRoutes);
This approach is like having a store directory in a shopping mall that can tell you if a specific item is in stock. The store (route) exists, but the particular item (resource) might not be available.
Scenario Three: API Endpoint Not Found
For API-focused applications, you might want to return JSON responses instead of HTML for not-found errors.
// For API routes
app.use('/api', (req, res, next) => {
// Check if this route didn't match any of your API routes
res.status(404).json({
error: 'Not Found',
message: 'The requested API endpoint does not exist',
path: req.originalUrl
});
});
// For regular routes (HTML responses)
app.use((req, res) => {
res.status(404).send('Page not found');
});
This differentiation allows you to provide appropriate error responses based on the type of request. It's like having different types of signage for different areas of a building - technical areas might have detailed diagrams, while public areas have more user-friendly guidance.
Creating a Central Error Handler
A central error handler in Express is like a hospital's emergency room - a specialized facility designed to handle all kinds of unexpected problems in a systematic way.
Basic Central Error Handler
// errorHandler.js
function errorHandler(err, req, res, next) {
// Log the error (in a real app, you might use a logging library)
console.error(err.stack);
// Set appropriate status code (default to 500 if not specified)
const statusCode = err.statusCode || 500;
// Set response based on request type
if (req.originalUrl.startsWith('/api')) {
// API error response
return res.status(statusCode).json({
error: err.message || 'Internal Server Error',
status: statusCode
});
}
// Web page error response
res.status(statusCode);
// Choose appropriate error view
if (statusCode === 404) {
return res.render('errors/404', { url: req.originalUrl });
}
// Default error view
res.render('errors/error', {
message: err.message || 'Something went wrong',
error: process.env.NODE_ENV === 'development' ? err : {}
});
}
module.exports = errorHandler;
// In your main app.js
const errorHandler = require('./errorHandler');
// Place this after all your routes
app.use(errorHandler);
This central error handler allows you to standardize error responses across your application, log errors consistently, and show appropriate error pages to users.
Creating Custom Error Types
For more advanced error handling, you can create custom error classes that extend from JavaScript's Error class. This is like creating specialized diagnostic tools in our hospital analogy - each designed for specific types of problems.
// errors/customErrors.js
class NotFoundError extends Error {
constructor(message) {
super(message);
this.name = 'NotFoundError';
this.statusCode = 404;
}
}
class UnauthorizedError extends Error {
constructor(message) {
super(message);
this.name = 'UnauthorizedError';
this.statusCode = 401;
}
}
class ForbiddenError extends Error {
constructor(message) {
super(message);
this.name = 'ForbiddenError';
this.statusCode = 403;
}
}
module.exports = {
NotFoundError,
UnauthorizedError,
ForbiddenError
};
// Usage in routes
const { NotFoundError } = require('./errors/customErrors');
router.get('/products/:id', (req, res, next) => {
const product = getProductById(req.params.id);
if (!product) {
// This error will be caught by your error handler
return next(new NotFoundError(`Product ${req.params.id} not found`));
}
res.json(product);
});
Custom error classes make your code more expressive and allow your error handler to provide more specific responses based on the error type. They also make debugging easier by clearly identifying the nature of errors.
Handling Errors in Async Routes
Asynchronous operations in Express routes (like database queries or API calls) require special handling for errors. Think of async operations as sending out scouts to gather information - you need a plan for when they encounter problems and a way for them to report back.
The Problem with Async Errors
// This won't work as expected
app.get('/users/:id', async (req, res) => {
// If this throws an error, Express won't catch it
const user = await database.findUser(req.params.id);
if (!user) {
return res.status(404).send('User not found');
}
res.json(user);
});
Errors in async functions can escape your normal error handling if not properly managed. There are several ways to handle this:
Solution 1: Try/Catch Blocks
app.get('/users/:id', async (req, res, next) => {
try {
const user = await database.findUser(req.params.id);
if (!user) {
return res.status(404).send('User not found');
}
res.json(user);
} catch (error) {
next(error); // Pass error to Express error handler
}
});
Solution 2: Express Async Handler Utility
// utilities/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
module.exports = asyncHandler;
// In your routes
const asyncHandler = require('../utilities/asyncHandler');
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await database.findUser(req.params.id);
if (!user) {
return res.status(404).send('User not found');
}
res.json(user);
}));
The asyncHandler utility wraps your async route handlers and automatically catches and forwards any errors to your Express error handling middleware. It's like having an automatic alert system for our scout team - if they encounter trouble, headquarters is automatically notified.
Alternatively, you can use the popular express-async-handler npm package which provides this same functionality:
const asyncHandler = require('express-async-handler');
app.get('/users/:id', asyncHandler(async (req, res) => {
// Your async code here
// No try/catch needed - errors will be passed to your error handler
}));
Router-Level Error Handling
For larger applications with multiple routers, you might want to implement router-specific error handling. This is like having specialized emergency teams in different departments of a hospital, each trained for the specific types of emergencies they're likely to encounter.
// routes/admin.js
const express = require('express');
const router = express.Router();
// Admin-specific middleware
router.use((req, res, next) => {
// Check if user is admin
if (!req.user || !req.user.isAdmin) {
return res.status(403).send('Admin access required');
}
next();
});
// Admin routes
router.get('/dashboard', (req, res) => {
res.send('Admin Dashboard');
});
// Admin-specific 404 handler
router.use((req, res) => {
res.status(404).send('Admin section: Page not found');
});
// Admin-specific error handler
router.use((err, req, res, next) => {
console.error('Admin Error:', err);
res.status(500).send('Admin section: Something went wrong');
});
module.exports = router;
// In your main app.js
const adminRouter = require('./routes/admin');
app.use('/admin', adminRouter);
Router-level error handlers catch errors that occur in that specific router. However, if they call next(err), the error will bubble up to the application-level error handler.
This approach allows you to have more specialized error handling for different sections of your application. For example, your admin section might show more detailed error information than your public-facing pages.
Real-World Example: E-commerce Application
Let's bring all these concepts together in a real-world example of an e-commerce site with multiple types of 404 errors and other error scenarios.
Project Structure
ecommerce_app/
├── config/
│ └── database.js
├── errors/
│ ├── customErrors.js
│ └── errorHandler.js
├── middleware/
│ ├── asyncHandler.js
│ └── auth.js
├── models/
│ ├── product.js
│ └── user.js
├── public/
│ ├── styles/
│ │ └── main.css
│ └── favicon.png
├── routes/
│ ├── products.js
│ ├── users.js
│ └── admin.js
├── views/
│ ├── errors/
│ │ ├── 404.ejs
│ │ └── error.ejs
│ └── /* other view templates */
├── app.js
└── server.js
Custom Error Classes (errors/customErrors.js)
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
this.isOperational = true; // Indicates this is an expected error
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(message) {
super(message || 'Resource not found', 404);
}
}
class ValidationError extends AppError {
constructor(message) {
super(message || 'Validation failed', 400);
}
}
class AuthenticationError extends AppError {
constructor(message) {
super(message || 'Authentication required', 401);
}
}
class ForbiddenError extends AppError {
constructor(message) {
super(message || 'Access forbidden', 403);
}
}
module.exports = {
AppError,
NotFoundError,
ValidationError,
AuthenticationError,
ForbiddenError
};
Central Error Handler (errors/errorHandler.js)
const { AppError } = require('./customErrors');
// Development error handler (more detailed)
const sendDevError = (err, req, res) => {
// API error response
if (req.originalUrl.startsWith('/api')) {
return res.status(err.statusCode).json({
status: err.status,
error: err,
message: err.message,
stack: err.stack
});
}
// Rendered webpage error
res.status(err.statusCode).render('errors/error', {
title: 'Error',
message: err.message,
error: err
});
};
// Production error handler (less detailed, more user-friendly)
const sendProdError = (err, req, res) => {
// For operational errors, send message to client
if (err.isOperational) {
// API error response
if (req.originalUrl.startsWith('/api')) {
return res.status(err.statusCode).json({
status: err.status,
message: err.message
});
}
// For 404 errors, use specific template
if (err.statusCode === 404) {
return res.status(404).render('errors/404', {
title: 'Page Not Found',
path: req.originalUrl
});
}
// Rendered webpage error
return res.status(err.statusCode).render('errors/error', {
title: 'Error',
message: err.message
});
}
// For programming or unknown errors, don't leak error details
console.error('ERROR 💥', err);
// API error response
if (req.originalUrl.startsWith('/api')) {
return res.status(500).json({
status: 'error',
message: 'Something went wrong'
});
}
// Rendered webpage error
res.status(500).render('errors/error', {
title: 'Error',
message: 'Something went wrong. Please try again later.'
});
};
// Error handler middleware
const errorHandler = (err, req, res, next) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || 'error';
const isDevEnvironment = process.env.NODE_ENV === 'development';
if (isDevEnvironment) {
sendDevError(err, req, res);
} else {
// Handle specific error types
let error = { ...err };
error.message = err.message;
// Mongoose duplicate key error
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
error = new AppError(`Duplicate field value: ${field}. Please use another value.`, 400);
}
// Mongoose validation error
if (err.name === 'ValidationError') {
const errors = Object.values(err.errors).map(val => val.message);
error = new AppError(`Invalid input data: ${errors.join('. ')}`, 400);
}
// Mongoose invalid ID error
if (err.name === 'CastError') {
error = new AppError(`Invalid ${err.path}: ${err.value}`, 400);
}
sendProdError(error, req, res);
}
};
module.exports = errorHandler;
Async Handler Utility (middleware/asyncHandler.js)
const asyncHandler = fn => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
module.exports = asyncHandler;
Product Router (routes/products.js)
const express = require('express');
const router = express.Router();
const { NotFoundError, ValidationError } = require('../errors/customErrors');
const asyncHandler = require('../middleware/asyncHandler');
const Product = require('../models/product');
// Get all products
router.get('/', asyncHandler(async (req, res) => {
const products = await Product.find();
res.json(products);
}));
// Get a single product
router.get('/:id', asyncHandler(async (req, res, next) => {
const product = await Product.findById(req.params.id);
if (!product) {
throw new NotFoundError(`Product with ID ${req.params.id} not found`);
}
res.json(product);
}));
// Create a product
router.post('/', asyncHandler(async (req, res, next) => {
const { name, price, description } = req.body;
if (!name || !price) {
throw new ValidationError('Product name and price are required');
}
const product = await Product.create({
name,
price,
description
});
res.status(201).json(product);
}));
module.exports = router;
Main Application (app.js)
const express = require('express');
const path = require('path');
const { NotFoundError } = require('./errors/customErrors');
const errorHandler = require('./errors/errorHandler');
const connectDB = require('./config/database');
// Import routes
const productRoutes = require('./routes/products');
const userRoutes = require('./routes/users');
const adminRoutes = require('./routes/admin');
// Initialize app
const app = express();
// Connect to database
connectDB();
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.join(__dirname, 'public')));
// Set view engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Routes
app.use('/api/products', productRoutes);
app.use('/api/users', userRoutes);
app.use('/admin', adminRoutes);
// Home route
app.get('/', (req, res) => {
res.render('home');
});
// Handle 404 errors for API routes
app.all('/api/*', (req, res, next) => {
next(new NotFoundError(`API endpoint not found: ${req.originalUrl}`));
});
// Handle 404 errors for regular routes
app.use((req, res, next) => {
next(new NotFoundError(`Page not found: ${req.originalUrl}`));
});
// Global error handler
app.use(errorHandler);
module.exports = app;
404 Error Template (views/errors/404.ejs)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Page Not Found</title>
<link rel="stylesheet" href="/styles/main.css">
<link rel="icon" href="/favicon.png">
</head>
<body>
<div class="error-container">
<h1>404</h1>
<h2>Page Not Found</h2>
<p>Sorry, we couldn't find the page you're looking for.</p>
<p>Path: <%= path %></p>
<a href="/" class="btn">Go Home</a>
</div>
</body>
</html>
Best Practices for Error Handling
When implementing error handling in Express applications, follow these best practices to ensure a robust, maintainable, and user-friendly error handling system:
For Developers
- Centralize error handling: Use a central error handling middleware to maintain consistency and reduce code duplication.
- Create custom error classes: Extend the built-in Error class to create domain-specific errors that carry additional information.
- Log errors properly: Log all errors with appropriate detail for debugging, but be careful not to log sensitive information.
- Use async error handling: Always properly catch and forward errors from async functions either with try/catch blocks or utility wrappers.
- Different environments, different outputs: Show detailed errors in development but sanitized, user-friendly messages in production.
For Users
- Clear error messages: Provide clear, non-technical error messages that users can understand.
- Appropriate status codes: Use correct HTTP status codes to help browsers and clients understand the nature of the error.
- Consistent error format: Maintain a consistent format for error responses, especially in APIs.
- Helpful guidance: Include guidance on how to resolve the error when possible.
- Branded error pages: Use styled, branded error pages that match your site's design to maintain trust and user experience.
Real-world example: Airbnb's 404 page shows their branding, a clear message about the page not being found, and most importantly, provides suggestions for popular destinations and a search bar to help users continue their journey. This turns a potential negative experience into an opportunity for continued engagement.
Conclusion
Robust error handling is not just a technical requirement but a critical component of a good user experience. Like a well-designed safety system in a building, proper error handling should work quietly in the background, but step in effectively when needed.
By implementing the patterns and practices discussed in this tutorial, you can create Express applications that gracefully handle unexpected situations, provide helpful feedback to users, and make debugging easier for developers.
Remember that error handling is an ongoing process. As your application grows and evolves, so too should your error handling strategies. Regularly review your error logs and user feedback to identify areas where your error handling can be improved.
With these tools and approaches, you're well-equipped to build resilient Express applications that provide a smooth experience even when things don't go exactly as planned.
Further Topics to Explore
- Error monitoring services: Tools like Sentry, Rollbar, or New Relic for tracking and analyzing errors in production
- Validation libraries: Using libraries like Joi or express-validator to prevent errors through validation
- API error standards: Exploring standards like JSON:API or Problem Details for HTTP APIs (RFC 7807)
- Rate limiting: Implementing rate limiting to prevent abuse and improve security
- Circuit breakers: Using circuit breaker patterns to handle failures in microservice architectures