Things go wrong. Databases timeout, users send invalid data, files go missing. Without proper error handling, your app crashes or leaks sensitive information. A good error handling strategy distinguishes professional applications from hobby projects.
The anatomy of an error handler
Error handling middlewareWhat is middleware?A function that runs between receiving a request and sending a response. It can check authentication, log data, or modify the request before your main code sees it. looks almost like regular middleware, but with one crucial difference: it has four parameters instead of three.
// Regular middleware: 3 parameters
app.use((req, res, next) => {
// ...
});
// Error handling middleware: 4 parameters
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Something broke!' });
});That extra err parameter at the beginning is how Express knows this middleware handles errors. Without it, Express treats it as regular middleware and won't route errors to it.
Creating custom error classes
Not all errors are equal. A user entering the wrong password is expected. A database connection failing unexpectedly is a bug. Custom error classes let you distinguish between them.
// utils/errors.js
// Base class for all application errors
export class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true; // Expected error vs unexpected bug
// Maintains proper stack trace
Error.captureStackTrace(this, this.constructor);
}
}
// Specific error types
export class ValidationError extends AppError {
constructor(message) {
super(message, 400);
}
}
export class NotFoundError extends AppError {
constructor(resource) {
super(`${resource} not found`, 404);
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401);
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 403);
}
}These error classes make your code more expressive:
// Instead of this:
throw new Error('User not found');
// Do this:
throw new NotFoundError('User');The centralized error handler
Here's a production-ready error handler that handles different error types appropriately:
// middleware/errorHandler.js
export const errorHandler = (err, req, res, next) => {
// Set defaults
err.statusCode = err.statusCode || 500;
err.message = err.message || 'Internal server error';
// Operational errors (expected) vs programming errors (bugs)
if (err.isOperational) {
// Expected error: tell the client what happened
return res.status(err.statusCode).json({
status: 'error',
message: err.message,
...(process.env.NODE_ENV === 'development' && {
stack: err.stack
})
});
}
// Programming error: log it, don't leak details
console.error('ERROR 💥', err);
// In production, send generic message
if (process.env.NODE_ENV === 'production') {
return res.status(500).json({
status: 'error',
message: 'Something went wrong'
});
}
// In development, show everything
return res.status(500).json({
status: 'error',
message: err.message,
stack: err.stack,
error: err
});
};The key insight: operational errors get detailed messages ("User not found"), while programming errors get generic ones ("Something went wrong"). You don't want to leak stack traces in production.
Throwing errors in routes
Express catches errors thrown in synchronous route handlers automatically. For async handlers, you need to handle them explicitly (or use a wrapper).
import { NotFoundError, ValidationError } from '../utils/errors.js';
// Synchronous - Express catches automatically
app.get('/users/:id', (req, res) => {
const user = findUser(req.params.id);
if (!user) {
throw new NotFoundError('User');
}
res.json(user);
});
// Async with try/catch
app.post('/users', async (req, res, next) => {
try {
if (!req.body.email) {
throw new ValidationError('Email is required');
}
const user = await createUser(req.body);
res.status(201).json(user);
} catch (err) {
next(err); // Pass to error handler
}
});
// Async with wrapper (cleaner)
app.delete('/users/:id', asyncHandler(async (req, res) => {
const deleted = await deleteUser(req.params.id);
if (!deleted) {
throw new NotFoundError('User');
}
res.status(204).send();
}));Handling async errors
Async errors need special attention because Express 4 doesn't catch rejected promises automatically. You have three options:
Option 1: Try/catch in every route
app.get('/data', async (req, res, next) => {
try {
const data = await fetchData();
res.json(data);
} catch (err) {
next(err);
}
});Option 2: Wrapper function (recommended)
const catchAsync = (fn) => {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
};
app.get('/data', catchAsync(async (req, res) => {
const data = await fetchData();
res.json(data);
}));Option 3: Express 5 (future)
// Express 5 handles async errors natively
app.get('/data', async (req, res) => {
const data = await fetchData(); // Errors caught automatically!
res.json(data);
});The wrapper approach (Option 2) is the current best practice for Express 4 applications.
Handling 404 errors
If a request doesn't match any of your routes, it should return 404. Place this handler after all your routes but before the error handler.
// This catches all HTTP methods and all paths
app.all('*', (req, res, next) => {
next(new NotFoundError(`Route ${req.originalUrl} not found`));
});Using app.all() instead of app.use() ensures it catches requests regardless of HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. method. The * pattern matches any URL.
Complete error handling setup
Here's how everything fits together in a complete Express app:
import express from 'express';
import { errorHandler } from './middleware/errorHandler.js';
import { NotFoundError } from './utils/errors.js';
import { asyncHandler } from './middleware/asyncHandler.js';
import routes from './routes/index.js';
const app = express();
// Regular middlewares
app.use(express.json());
// Routes
app.use('/api', routes);
// 404 handler - must be after all routes
app.all('*', (req, res, next) => {
next(new NotFoundError(`Route ${req.originalUrl} not found`));
});
// Global error handler - must be last!
app.use(errorHandler);
export default app;The order is crucial:
- Regular middlewares (body parsing, logging, etc.)
- Routes
- 404 handler
- Error handler
Quick reference: error handling patterns
| Pattern | Code example | When to use |
|---|---|---|
| Sync error | throw new Error() | Synchronous route handlers |
| Async with try/catch | try { ... } catch(err) { next(err) } | When you need custom error handling |
| Async wrapper | catchAsync(async (req, res) => { ... }) | Clean async error handling |
| Operational error | throw new AppError(msg, 400) | Expected errors (validation, not found) |
| 404 handler | app.all('*', handler) | After all routes |
Good error handling is invisible when things work, but invaluable when they don't. Take the time to set it up properly, your future self (and your users) will thank you.