Reliable error handling is what separates a fragile Express app from a production-ready one. This lesson brings together the patterns from earlier lessons into a complete strategy.
A Complete Error Handling Strategy
A solid strategy has three parts: catch errors close to where they happen (try/catch or an async wrapper), forward them consistently with next(err), and handle them once, centrally, at the bottom of the middleware stack.
This keeps individual routes simple, they only need to catch and forward, while all formatting, logging, and status-code decisions live in one place.
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);
if (!user) {
const err = new Error('User not found');
err.status = 404;
throw err;
}
res.json(user);
}));
app.use((err, req, res, next) => {
const status = err.status || 500;
if (status === 500) console.error(err.stack);
res.status(status).json({ error: status === 500 ? 'Internal server error' : err.message });
});
Throwing inside asyncHandler's wrapped function is automatically caught and forwarded to the centralized error handler.
Attaching HTTP Status to Errors
const err = new Error('Not found');
err.status = 404;
next(err);
// or with a custom error class (see Custom Errors lesson)
throw new NotFoundError('User not found');
Attach a status property to errors so the centralized handler knows which HTTP code to use.
Default to 500 for anything without an explicit status, treating it as unexpected.
Log full stack traces server-side for 500-level errors; never expose them to clients.
For expected failures (validation, not found, unauthorized), throw with the correct status directly.
Error Handling Cheatsheet
How to classify and respond to common error scenarios.
Error Type
Status Code
Client Message
Validation failure
400
Specific field errors
Missing/invalid auth
401
'Unauthorized'
Insufficient permissions
403
'Forbidden'
Resource not found
404
'Resource not found'
Duplicate/conflict
409
'Resource already exists'
Unexpected/bug
500
'Internal server error' (generic)
Operational vs Programmer Errors
Operational errors are expected failure modes your code anticipates, invalid input, a missing record, a failed external API call. Programmer errors are bugs, a typo, calling a function with the wrong arguments, an undefined variable. Treat them differently: operational errors get a clean, specific client response; programmer errors should be logged loudly and generally shouldn't reveal details to the client.
Process-Level Safety Nets
Even with careful in-app error handling, listen for uncaughtException and unhandledRejection at the process level as a last resort, logging the failure and shutting down gracefully rather than continuing in an unknown state.
Letting async errors go unhandled instead of using try/catch or an async wrapper.
Exposing raw stack traces or database error messages directly to API clients.
Treating every error the same way instead of distinguishing expected failures from bugs.
Having multiple inconsistent error-response shapes scattered across the codebase.
Key Takeaways
Catch errors close to the source, forward them with next(err), handle them centrally.
Attach a status property to errors so the central handler can respond correctly.
Distinguish operational errors (expected) from programmer errors (bugs) in how you log and respond.
Add process-level safety nets for truly unexpected failures as a last line of defense.
Pro Tip
Log the full error object server-side (console.error(err) or a real logger) for every 500-level response, but only ever send the client a short, generic message, detailed internals should never leave your server.
You now have a complete error handling strategy. Next, learn how to build your own error classes in the Custom Errors lesson.