Two things are certain in every Express API: some requests will arrive for routes that do not exist, and some requests that do match a route will trigger errors โ database failures, validation problems, invalid IDs, or unexpected exceptions. Handling both cleanly โ with consistent JSON responses, correct HTTP status codes, and no unhandled promise rejections crashing your server โ is what distinguishes a production-ready API from a prototype. In this lesson you will build a complete Express error handling layer covering 404 catch-all handlers, global error middleware, custom error classes, and the async wrapper pattern.
Express Error Handling โ The Four Rules
| Rule | Detail |
|---|---|
| 404 handler goes last | Define the not-found handler after all routes โ it catches any request that did not match |
| Error middleware has 4 arguments | (err, req, res, next) โ Express identifies it as error middleware by the extra parameter |
| Error middleware goes after everything | Registered last with app.use() โ after all routes and the 404 handler |
| Pass errors with next(err) | Call next(err) from any route or middleware to skip to the error handler |
throw inside async route handlers are automatically passed to your error middleware โ you do not need try/catch in every handler. In Express 4, an uncaught error inside an async function causes an unhandled promise rejection that does NOT trigger your error middleware. You must either use try/catch + next(err), or wrap handlers with an async wrapper utility.AppError class that extends the built-in Error and accepts a status code. Throw new AppError('Post not found', 404) anywhere in your controllers, and your global error middleware reads the statusCode from the error object to send the right HTTP response. This keeps status code logic out of your controllers entirely.next(err) with a non-Error value such as a string or plain object. Express expects an actual Error instance (or subclass). If you pass next('something went wrong'), Express may not handle it correctly across all versions. Always: next(new Error('message')) or next(new AppError('message', 400)).The 404 Not Found Handler
// server/src/middleware/notFound.js
const notFound = (req, res, next) => {
const error = new Error(`Route not found: ${req.method} ${req.originalUrl}`);
error.statusCode = 404;
next(error); // pass to the global error handler
};
module.exports = notFound;
// In index.js โ registered AFTER all routes:
app.use('/api/posts', require('./src/routes/posts'));
app.use('/api/auth', require('./src/routes/auth'));
app.use(notFound); // 404 catch-all โ after all routes
app.use(errorHandler); // global error handler โ very last
Global Error Middleware
// server/src/middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
// Log the full error server-side (never expose stack traces to clients)
console.error(`[ERROR] ${err.name}: ${err.message}`);
if (process.env.NODE_ENV === 'development') {
console.error(err.stack);
}
// Default status code and message
let statusCode = err.statusCode || err.status || 500;
let message = err.message || 'Internal Server Error';
// โโ Handle specific Mongoose / MongoDB error types โโโโโโโโโโโโโโโโโโโโโโโโโโ
// CastError: invalid MongoDB ObjectId format
if (err.name === 'CastError' && err.kind === 'ObjectId') {
statusCode = 400;
message = `Invalid ID format: ${err.value}`;
}
// ValidationError: Mongoose schema validation failed
if (err.name === 'ValidationError') {
statusCode = 400;
message = Object.values(err.errors).map(e => e.message).join(', ');
}
// MongoServerError code 11000: duplicate key (e.g. email already exists)
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
statusCode = 409;
message = `${field} already exists`;
}
// JsonWebTokenError: invalid JWT
if (err.name === 'JsonWebTokenError') {
statusCode = 401;
message = 'Invalid token';
}
// TokenExpiredError: expired JWT
if (err.name === 'TokenExpiredError') {
statusCode = 401;
message = 'Token expired โ please log in again';
}
res.status(statusCode).json({
success: false,
message,
// Only include stack trace in development
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
});
};
module.exports = errorHandler;
Custom AppError Class
// server/src/utils/AppError.js
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
this.isOperational = true; // distinguishes known errors from unexpected bugs
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
// Usage anywhere in controllers:
const AppError = require('../utils/AppError');
if (!post) throw new AppError('Post not found', 404);
if (!user) throw new AppError('User not found', 404);
if (post.author.toString() !== req.user.id) throw new AppError('Not authorised', 403);
The Async Wrapper (Express 4 Only)
// server/src/utils/asyncHandler.js
// Wraps an async function so thrown errors are passed to next() automatically
// Not needed in Express 5 โ but essential for Express 4
const asyncHandler = fn => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
module.exports = asyncHandler;
// โโ Without asyncHandler (Express 4) โ verbose โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const getPostById = async (req, res, next) => {
try {
const post = await Post.findById(req.params.id);
if (!post) return next(new AppError('Post not found', 404));
res.json({ success: true, data: post });
} catch (err) {
next(err); // must manually catch and forward every error
}
};
// โโ With asyncHandler (Express 4) โ clean โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const asyncHandler = require('../utils/asyncHandler');
const getPostById = asyncHandler(async (req, res) => {
const post = await Post.findById(req.params.id);
if (!post) throw new AppError('Post not found', 404); // throw instead of next()
res.json({ success: true, data: post });
}); // errors automatically forwarded to global error handler โ
Complete Error Flow โ End to End
Request: GET /api/posts/invalid-id-format
1. Express matches route: GET /api/posts/:id โ getPostById handler
2. getPostById: await Post.findById('invalid-id-format')
3. Mongoose throws CastError: Cast to ObjectId failed for value "invalid-id-format"
4. asyncHandler catches the error โ calls next(castError)
5. Express skips all remaining route/middleware โ jumps to errorHandler (4 args)
6. errorHandler: err.name === 'CastError' && err.kind === 'ObjectId'
โ statusCode = 400, message = 'Invalid ID format: invalid-id-format'
7. Response sent: 400 { "success": false, "message": "Invalid ID format: invalid-id-format" }
Request: GET /api/posts/nonexistent-but-valid-objectid
1. Express matches route โ getPostById handler
2. await Post.findById(validId) โ returns null (document not found)
3. throw new AppError('Post not found', 404) โ next(appError) via asyncHandler
4. errorHandler: err.statusCode = 404
โ message = 'Post not found'
5. Response sent: 404 { "success": false, "message": "Post not found" }
Common Mistakes
Mistake 1 โ Error middleware with only 3 parameters
โ Wrong โ Express does not recognise it as error middleware:
app.use((err, res, next) => { // missing req โ Express sees 3 params โ not error middleware!
res.status(500).json({ message: err.message });
});
โ Correct โ error middleware must have exactly 4 parameters, even if you do not use all of them:
app.use((err, req, res, next) => { // 4 params โ Express identifies as error middleware โ
res.status(500).json({ message: err.message });
});
Mistake 2 โ Registering error middleware before routes
โ Wrong โ error middleware defined before routes never receives errors from those routes:
app.use(errorHandler); // registered first โ too early
app.use('/api/posts', postsRouter); // errors here bypass errorHandler
โ Correct โ error middleware is always last:
app.use('/api/posts', postsRouter);
app.use(notFound);
app.use(errorHandler); // always last โ
Mistake 3 โ Throwing errors without using next() in Express 4
โ Wrong โ throwing inside an async route without asyncHandler causes an unhandled rejection:
app.get('/api/posts/:id', async (req, res) => {
const post = await Post.findById(req.params.id);
if (!post) throw new Error('Not found'); // unhandled rejection in Express 4 โ crash
});
โ Correct โ wrap with asyncHandler or use try/catch + next(err):
app.get('/api/posts/:id', asyncHandler(async (req, res) => {
const post = await Post.findById(req.params.id);
if (!post) throw new AppError('Not found', 404); // safely caught โ
}));
Quick Reference
| Task | Code |
|---|---|
| 404 handler | app.use((req,res,next) => next(new AppError('Not found',404))) |
| Global error middleware | app.use((err,req,res,next) => res.status(err.statusCode||500).json(...)) |
| Throw known error | throw new AppError('Message', 404) |
| Forward to error handler | next(new AppError('Message', 400)) |
| Wrap async handler (Exp 4) | asyncHandler(async (req,res) => { ... }) |
| Check error type | err.name === 'CastError', err.code === 11000 |
| Registration order | routes โ notFound โ errorHandler |