The Two Types of Errors
In Express, errors fall into two categories:
| Type | Description | Example |
|---|---|---|
| 404 Not Found | No route matched the request | GET /nonexistent |
| Application Error | A route handler threw or failed | Database connection failed |
Express needs different strategies for each.
Client Server
│ │
│── GET /api/nonexistent ──────────→│ No matching route
│←── 404 { error: "Route not found" }│
│ │
│── GET /api/users ────────────────→│ Database crashes
│←── 500 { error: "Internal error" }│
│ │
The 404 Catch-All Handler
Place a catch-all middleware at the end of your route stack. If no route matched, this runs:
const express = require('express');
const app = express();
// All your routes
app.get('/', (req, res) => res.send('Home'));
app.get('/about', (req, res) => res.send('About'));
// ⚠️ 404 handler - must be AFTER all routes
app.use((req, res) => {
res.status(404).json({
error: 'Not Found',
message: `Route ${req.method} ${req.originalUrl} not found`,
timestamp: new Date().toISOString()
});
});
app.listen(3000);
Why it works
Express runs middleware in order. When no route matches, it falls through all route handlers to the last app.use(), which catches every unmatched request.
Custom 404 Responses
You can tailor 404 responses to your needs:
// API 404 (JSON)
app.use('/api', (req, res) => {
res.status(404).json({
error: 'Not Found',
path: req.originalUrl
});
});
// Website 404 (HTML)
app.use((req, res) => {
res.status(404).sendFile(path.join(__dirname, 'public', '404.html'));
});
// Dynamic 404 with template engine
app.use((req, res) => {
res.status(404).render('404', {
title: 'Page Not Found',
url: req.originalUrl
});
});
Error-Handling Middleware
Express identifies error-handling middleware by its four parameters (err, req, res, next):
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Something went wrong!' });
});
Place it after all routes and the 404 handler:
// 1. Routes
app.get('/', ...);
app.get('/users', ...);
// 2. 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
// 3. Error handler
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
});
Passing Errors to the Handler
Use next(err) to pass errors from route handlers:
// Async route that might fail
app.get('/users/:id', async (req, res, next) => {
try {
const user = await db.findUser(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
} catch (err) {
next(err); // Pass to error handler
}
});
// Sync route
app.get('/risky', (req, res, next) => {
const result = someRiskyOperation();
if (result instanceof Error) {
return next(result);
}
res.json(result);
});
Express 5: Automatic Async Error Handling
Express 5 (alpha/beta) catches rejected promises automatically. In Express 4, you must wrap manually or use a helper:
// Helper to avoid try/catch in every route
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await db.findUser(req.params.id);
res.json(user);
}));
Custom Error Classes
Create a custom error class to structure errors:
// errors/AppError.js
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
// Usage
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) {
throw new AppError('User not found', 404);
}
Extended Error Types
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404);
}
}
class ValidationError extends AppError {
constructor(errors) {
super('Validation failed', 422);
this.errors = errors;
}
}
class AuthError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401);
}
}
// Usage
if (!user) throw new NotFoundError('User');
Centralized Error Handler
A robust error handler that handles different error types:
// middleware/errorHandler.js
function errorHandler(err, req, res, next) {
// Default values
let statusCode = err.statusCode || 500;
let message = err.message || 'Internal server error';
// Log the error
console.error(`[${new Date().toISOString()}] ${err.stack || err}`);
// Mongoose validation error
if (err.name === 'ValidationError') {
statusCode = 422;
message = 'Validation failed';
const errors = Object.values(err.errors).map(e => e.message);
return res.status(statusCode).json({ error: message, details: errors });
}
// Mongoose duplicate key
if (err.code === 11000) {
statusCode = 409;
message = 'Duplicate key error';
const field = Object.keys(err.keyValue).join(', ');
return res.status(statusCode).json({ error: message, field });
}
// JWT errors
if (err.name === 'JsonWebTokenError') {
statusCode = 401;
message = 'Invalid token';
}
if (err.name === 'TokenExpiredError') {
statusCode = 401;
message = 'Token expired';
}
// Default error response
res.status(statusCode).json({
error: message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
});
}
module.exports = errorHandler;
Full Production Setup
const express = require('express');
const path = require('path');
const errorHandler = require('./middleware/errorHandler');
const AppError = require('./errors/AppError');
const app = express();
// Built-in middleware
app.use(express.json());
// Routes
app.use('/api/users', require('./routes/users'));
app.use('/api/posts', require('./routes/posts'));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
// 404 handler - must be after all routes
app.all('*', (req, res, next) => {
next(new AppError(`Route ${req.originalUrl} not found`, 404));
});
// Global error handler
app.use(errorHandler);
app.listen(3000);
Throwing Errors vs next(err)
// ❌ This crashes the app (unhandled)
app.get('/fail', (req, res) => {
throw new Error('Boom!');
});
// ✅ This sends a 500 to the client
app.get('/fail', (req, res, next) => {
next(new Error('Boom!'));
});
// ✅ Using express-async-errors (requires npm install)
require('express-async-errors');
app.get('/fail', async (req, res) => {
throw new Error('This works with express-async-errors');
});
HTTP Status Codes for Errors
| Code | Meaning | When |
|---|---|---|
| 400 | Bad Request | Invalid input, missing fields |
| 401 | Unauthorized | Missing/invalid authentication |
| 403 | Forbidden | Authenticated but not permitted |
| 404 | Not Found | Resource doesn’t exist |
| 409 | Conflict | Duplicate entry, state conflict |
| 422 | Unprocessable | Validation errors |
| 429 | Too Many Requests | Rate limited |
| 500 | Internal Server | Unexpected server failure |
| 503 | Service Unavailable | Maintenance, overload |
Logging Errors
Integrate a proper logging library:
// Using Winston
const winston = require('winston');
const logger = winston.createLogger({
level: 'error',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log' }),
new winston.transports.Console({ format: winston.format.simple() })
]
});
function errorHandler(err, req, res, next) {
logger.error({
message: err.message,
stack: err.stack,
url: req.originalUrl,
method: req.method,
ip: req.ip,
timestamp: new Date().toISOString()
});
res.status(err.statusCode || 500).json({
error: err.isOperational ? err.message : 'Internal server error'
});
}
Testing Error Handling
// Test 404
fetch('http://localhost:3000/nonexistent')
.then(res => console.log(res.status)); // 404
// Test server error
fetch('http://localhost:3000/fail')
.then(res => console.log(res.status)); // 500
// Test validation error
fetch('http://localhost:3000/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
}).then(res => console.log(res.status)); // 422
Key Takeaways
- Place 404 handler after all route definitions
- Error-handling middleware has 4 params:
(err, req, res, next) - Pass errors with
next(err)- never throw synchronously in handlers - Use custom error classes for structured, consistent errors
- Centralize error handling into one middleware function
- Log errors for debugging and monitoring
- Include stack traces only in development mode
- Always distinguish between operational errors (expected) and programmer errors (bugs)