Every application fails sometimes: databases disconnect, clients send garbage, code has bugs. The difference between amateur and production-grade Express is what happens next. This lesson covers the error-handling middleware pattern, async errors, custom error classes, and a clean 404 strategy, giving you a template you can drop into any project.
Express recognizes a special middleware signature with four parameters. When any handler passes an error to next(err) or throws, Express skips all remaining normal middleware and jumps straight to it:
app.use((err, req, res, next) => {
console.error(err.stack);
const status = err.statusCode || 500;
res.status(status).json({
error: status === 500 ? 'Internal server error' : err.message,
});
});Three rules: it must have exactly four parameters (Express detects the arity), it must be registered after all routes, and for 500s it should log the real error while sending the client a generic message. Leaking stack traces to users is both ugly and a security risk.
Synchronous throws are caught automatically. In Express 5, rejected promises from async handlers are forwarded automatically too, which removes an entire class of historical boilerplate:
app.get('/api/books/:id', async (req, res) => {
const book = await Book.findById(req.params.id); // rejection goes to the error handler
if (!book) {
const err = new Error('Book not found');
err.statusCode = 404;
throw err;
}
res.json({ data: book });
});If you are stuck on Express 4, wrap async handlers or use the express-async-errors package; otherwise unhandled rejections bypass your error middleware entirely.
Attaching statusCode by hand gets repetitive. A tiny class makes intent explicit:
// errors/HttpError.js
class HttpError extends Error {
constructor(statusCode, message) {
super(message);
this.statusCode = statusCode;
}
}
module.exports = HttpError;const HttpError = require('./errors/HttpError');
app.get('/api/orders/:id', async (req, res) => {
const order = await Order.findById(req.params.id);
if (!order) throw new HttpError(404, 'Order not found');
res.json({ data: order });
});Now the error handler can trust any HttpError to carry a safe, client-facing message, while everything else defaults to a generic 500.
Databases throw their own error types. Translate the common ones in your error middleware so clients get accurate codes:
app.use((err, req, res, next) => {
if (err.name === 'ValidationError') {
return res.status(400).json({ error: err.message });
}
if (err.name === 'CastError') {
return res.status(400).json({ error: 'Invalid id format' });
}
console.error(err);
res.status(err.statusCode || 500).json({
error: err.statusCode ? err.message : 'Internal server error',
});
});A missing route is not an exception, just an unmatched request. Handle it with a normal middleware placed after every route but before the error handler:
app.use((req, res) => {
res.status(404).json({ error: 'Route ' + req.originalUrl + ' not found' });
});The final ordering of any Express app: parsers and loggers, routes, 404 catch-all, error handler. Memorize that sequence.
Next lesson: Authentication with Sessions and JWT, where we control who can call these endpoints.