π‘οΈ Error Handling Strategies
Things go wrong: users send bad data, databases time out, tokens expire. The difference between a fragile app and a resilient one is how it handles failure. This lesson builds a complete Express error-handling system β from custom error classes to centralized middleware, logging, and user-friendly responses.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Distinguish operational, programming, and system errors and respond to each appropriately
- Write error-handling middleware and pick the correct HTTP status code
- Design custom error classes that carry status codes and details
- Handle errors in asynchronous code and catch unhandled rejections globally
- Centralize error handling with logging, monitoring, and consistent, safe responses
Estimated Time: 45β60 minutes β’ Difficulty: Intermediate
Hands-on: Build a custom error class hierarchy and a single centralized error handler.
In This Lesson
How Express Handles Errors
Express has a built-in mechanism for errors. When you call next(err) with an argument β or, in Express 5, when an async handler rejects β Express skips all remaining normal middleware and jumps straight to your error-handling middleware (the one with four parameters). Your job is to make that landing spot smart: log the problem, choose the right status code, and return a clean response.
πͺ The safety-net analogy. Error handling is like the safety nets at a circus. The performers (your handlers) attempt complex acts. Local nets (try/catch blocks) catch falls in specific spots. One big main net (the centralized error handler) spans the whole show to catch anything the local nets miss. And the emergency team (logging and monitoring) assesses every fall so the same accident doesn't happen twice.
Types of Errors
Not all errors are equal. Classifying them tells you whether to handle-and-respond or to crash-and-fix:
- Operational errors β expected runtime conditions you should handle gracefully: invalid input, auth failure, resource not found, a database timeout, rate-limit exceeded. These are normal; return a helpful response.
- Programming errors β bugs in your code: type errors, reading undefined, wrong logic. These should be fixed, not papered over. In production they surface as generic 500s while you get an alert.
- System errors β the environment failing: out of memory, disk full, network reset. Often unrecoverable at the request level; handle at the process level.
Choosing the right status code
| Code | Name | Use when⦠|
|---|---|---|
| 400 | Bad Request | The client sent malformed or invalid data |
| 401 | Unauthorized | Authentication is missing or invalid |
| 403 | Forbidden | Authenticated, but not permitted |
| 404 | Not Found | The resource doesn't exist |
| 409 | Conflict | Request conflicts with current state (e.g. duplicate email) |
| 422 | Unprocessable Entity | Well-formed but semantically invalid (validation) |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | An unexpected error on the server |
| 503 | Service Unavailable | Server temporarily down or overloaded |
β οΈ 4xx is the client's fault, 5xx is yours
Getting this split right matters for monitoring: a spike in 4xx usually means clients are misusing the API; a spike in 5xx means you have a bug or an outage. Never return 500 for a validation failure, and never return 400 for a database crash.
Error-Handling Middleware
The foundation is a single middleware with four parameters, registered after all your routes. Here's a solid, security-aware version:
// Registered LAST, after all routes and other middleware
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
let message = err.message || 'Something went wrong';
// Log everything server-side
console.error(`[ERROR] ${req.method} ${req.path} β ${statusCode}`, err.stack);
// Never leak internal 500 details to clients in production
if (statusCode === 500 && process.env.NODE_ENV === 'production') {
message = 'An unexpected error occurred';
}
const body = { success: false, error: { message } };
if (err.details) body.error.details = err.details;
if (err.code) body.error.code = err.code;
res.status(statusCode).json(body);
});
You also want a 404 handler for routes that don't match anything. Place it after your routes but before the error handler β it manufactures an error and forwards it:
// 404 β no route matched. Runs after all routes.
app.use((req, res, next) => {
const err = new Error(`Not Found - ${req.originalUrl}`);
err.statusCode = 404;
next(err); // hand off to the error handler above
});
π‘ One handler, many error types
Rather than chaining several error middlewares, most production apps use one central handler that inspects the error (its name, statusCode, or class) and formats accordingly. We build exactly that in the "Centralized" section β it's easier to reason about and test.
Custom Error Classes
Throwing a bare new Error('User not found') loses information β the handler can't tell it should be a 404. Custom error classes attach the status code and details right to the error, so any handler downstream knows exactly what to do.
// errors/ApiError.js β the base class
class ApiError extends Error {
constructor(message, statusCode, details = null) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.details = details;
this.isOperational = true; // distinguishes expected errors from bugs
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = ApiError;
Then a small family of specific errors, each setting a sensible default status:
const ApiError = require('./ApiError');
class BadRequestError extends ApiError {
constructor(message = 'Bad Request', details = null) { super(message, 400, details); }
}
class NotFoundError extends ApiError {
constructor(resource = 'Resource') { super(`${resource} not found`, 404); }
}
class UnauthorizedError extends ApiError {
constructor(message = 'Authentication required') { super(message, 401); }
}
class ForbiddenError extends ApiError {
constructor(message = 'Access denied') { super(message, 403); }
}
class ValidationError extends ApiError {
constructor(details) { super('Validation failed', 422, details); }
}
module.exports = { ApiError, BadRequestError, NotFoundError, UnauthorizedError, ForbiddenError, ValidationError };
Now handlers read like plain English, and the error carries its own status code:
const { NotFoundError, ForbiddenError } = require('./errors');
app.get('/api/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User'); // β 404
if (req.user.role !== 'admin' && req.user.id !== user.id) {
throw new ForbiddenError('You can only view your own profile'); // β 403
}
res.json(user);
} catch (err) {
next(err); // the central handler reads err.statusCode
}
});
// The central handler recognizes any ApiError
const { ApiError } = require('./errors');
app.use((err, req, res, next) => {
if (err instanceof ApiError) {
return res.status(err.statusCode).json({
success: false,
error: { message: err.message, ...(err.details && { details: err.details }) },
});
}
// Unknown error = treat as 500
console.error(err);
res.status(500).json({
success: false,
error: { message: process.env.NODE_ENV === 'production' ? 'An unexpected error occurred' : err.message },
});
});
π The isOperational flag
The isOperational property lets you tell "expected" errors (a 404 you threw on purpose) from genuine bugs. Some teams use it to decide whether to alert on-call or restart the process: operational errors get a clean response; non-operational ones get escalated.
Asynchronous Error Handling
Most real errors happen in async code. If a promise rejects and nobody catches it, the request stalls (Express 4) or crashes the process (unhandled rejection). Three patterns keep you safe.
1. try/catch around await
app.get('/api/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User');
res.json(user);
} catch (err) {
next(err);
}
});
2. An asyncHandler wrapper
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/api/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User');
res.json(user);
}));
π‘ Express 5 auto-catches async errors
On Express 5, a rejected promise from an async handler is automatically forwarded to your error middleware β no wrapper needed. On Express 4, use asyncHandler (or the express-async-handler package). Either way, the error handler stays the same.
3. Global last-resort handlers
Even careful code can miss a rejection. Set up process-level guards so a stray error doesn't silently take down your server:
process.on('unhandledRejection', (reason) => {
console.error('Unhandled Rejection:', reason);
// Log to monitoring; in serious cases, shut down gracefully.
});
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
// An uncaught exception leaves the process in an unknown state β
// log, then exit and let your process manager (PM2/systemd) restart it.
server.close(() => process.exit(1));
setTimeout(() => process.exit(1), 10_000).unref();
});
Centralized Error Handling
As an app grows, you want one place that knows how to turn any error β from Mongoose, Joi, JWT, or your own classes β into a consistent response. The trick is a normalizer that maps known error shapes onto your ApiError format:
// middleware/errorHandler.js
const { ApiError } = require('../errors');
const logger = require('../utils/logger');
// Map third-party error types to a standard ApiError
const normalize = (err) => {
if (err instanceof ApiError) return err;
// Mongoose duplicate key
if (err.code === 11000) {
const field = Object.keys(err.keyValue || {})[0];
return new ApiError('A record with that value already exists', 409, { field });
}
// Mongoose validation
if (err.name === 'ValidationError') {
const details = Object.values(err.errors).map((e) => ({ field: e.path, message: e.message }));
return new ApiError('Validation failed', 422, details);
}
// JWT
if (err.name === 'JsonWebTokenError') return new ApiError('Invalid token', 401);
if (err.name === 'TokenExpiredError') return new ApiError('Token has expired', 401);
// Unknown β generic 500 (a probable bug)
return new ApiError('An unexpected error occurred', 500);
};
const errorHandler = (err, req, res, next) => {
const e = normalize(err);
// Log with severity based on status
const context = { path: req.path, method: req.method, requestId: req.id };
if (e.statusCode >= 500) {
logger.error({ message: e.message, stack: err.stack, ...context });
} else {
logger.warn({ message: e.message, ...context });
}
const body = { success: false, error: { message: e.message } };
if (e.details) body.error.details = e.details;
if (process.env.NODE_ENV === 'development') body.error.stack = err.stack;
res.status(e.statusCode).json(body);
};
module.exports = errorHandler;
With this in place, your route handlers never format errors β they just throw or next(err), and one well-tested function handles the rest. This is exactly how large teams keep error responses uniform across hundreds of endpoints.
Logging & Monitoring
An error you don't record is an error you can't fix. In production you need structured logs (machine-parseable JSON) and, ideally, an error-tracking service that groups and alerts.
Structured logging with Winston
// utils/logger.js
const winston = require('winston');
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json(),
),
defaultMeta: { service: 'api' },
transports: [
new winston.transports.Console(),
...(process.env.NODE_ENV === 'production'
? [new winston.transports.File({ filename: 'logs/error.log', level: 'error' })]
: []),
],
});
module.exports = logger;
Error tracking with Sentry
// npm install @sentry/node
const Sentry = require('@sentry/node');
Sentry.init({ dsn: process.env.SENTRY_DSN, environment: process.env.NODE_ENV });
// In modern @sentry/node, set up the Express error handler after your routes:
Sentry.setupExpressErrorHandler(app);
// Then your own error handler runs last
app.use(errorHandler);
β What good monitoring gives you
- Grouping β a thousand identical errors become one alert
- Context β request, user, environment, and a request ID to correlate logs
- Alerting β get paged on a 500 spike before users complain
- Trends β spot a regression the moment a deploy ships it
User-Friendly Responses
A good error response helps the caller fix the problem without exposing anything sensitive. Aim for a consistent envelope with a clear message, an optional machine-readable code, field-level details, and a request ID for support:
A well-structured error response
{
"success": false,
"error": {
"message": "Validation failed",
"code": "VALIDATION_ERROR",
"details": [
{ "field": "email", "message": "Email format is invalid" },
{ "field": "password", "message": "Password must be at least 8 characters" }
],
"documentation": "https://api.example.com/docs/errors#validation"
},
"requestId": "7f77cc43-2019-4b55-9c7f-3b97238e0810"
}
β οΈ Never leak internals
In production, a 500 response should say "An unexpected error occurred" β not a stack trace, SQL string, or file path. Those help attackers and confuse users. Log the full detail server-side; return the minimum the client needs.
Consistency is the theme running through this whole lesson: the same envelope on every endpoint, in every error path, so clients can write one error-handling routine that works everywhere. That predictability is exactly why Stripe, GitHub, and Twilio are praised for their developer experience.
Hands-on Exercise
ποΈ A Custom Error Hierarchy + Central Handler
Objective: Wire up custom error classes and one centralized handler, then prove they produce correct status codes.
Instructions:
- Create
ApiError(base) plusNotFoundError(404) andBadRequestError(400) that extend it. - Write one error-handling middleware that returns
{ success:false, error:{ message, details? } }and useserr.statusCode || 500. - Add a route
GET /api/items/:idthat throwsNotFoundError('Item')when the id isn't1. - Add a
404catch-all and register the error handler last. - Test:
GET /api/items/1β 200,GET /api/items/9β 404,GET /nopeβ 404 with the "Not Found" message.
π‘ Hint
Order matters: routes first, then the 404 catch-all (app.use((req,res,next)=>...)), then the four-parameter error handler last. Throwing inside a synchronous handler is fine; Express routes the thrown error to the error middleware automatically.
β Example solution
const express = require('express');
const app = express();
app.use(express.json());
// --- Error classes ---
class ApiError extends Error {
constructor(message, statusCode, details = null) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.details = details;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends ApiError {
constructor(resource = 'Resource') { super(`${resource} not found`, 404); }
}
class BadRequestError extends ApiError {
constructor(message = 'Bad Request', details = null) { super(message, 400, details); }
}
// --- Route ---
app.get('/api/items/:id', (req, res) => {
if (req.params.id !== '1') throw new NotFoundError('Item');
res.json({ success: true, data: { id: 1, name: 'Widget' } });
});
// --- 404 catch-all (after routes) ---
app.use((req, res, next) => {
next(new NotFoundError(`Route ${req.originalUrl}`));
});
// --- Centralized error handler (LAST, four params) ---
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
const body = { success: false, error: { message: err.message } };
if (err.details) body.error.details = err.details;
if (statusCode >= 500) console.error(err.stack);
res.status(statusCode).json(body);
});
app.listen(3000, () => console.log('http://localhost:3000'));
π― Quick Quiz
Question 1: A user submits a form missing a required field. Which status code is most appropriate?
Question 2: What is the main advantage of custom error classes like NotFoundError?
Question 3: Where must the four-parameter error-handling middleware be registered?
Best Practices
β Do
- Use custom error classes that carry
statusCodeanddetails - Centralize formatting in one error handler registered last
- Distinguish 4xx (client) from 5xx (server) correctly
- Catch async errors β try/catch, a wrapper, or Express 5's auto-catch
- Log with structured context and a request ID; integrate monitoring
- Add process-level guards for unhandled rejections/exceptions
β οΈ Don't
- Don't return stack traces or internal messages to clients in production
- Don't return 200 (or 500) for validation failures
- Don't swallow errors silently β always log or forward them
- Don't scatter error formatting across every route
- Don't keep running after an
uncaughtExceptionβ log and restart
Summary & Quiz
π Key Takeaways
- Express routes forwarded/thrown errors to four-parameter error middleware registered last.
- Classify errors as operational, programming, or system β and pick the correct status code.
- Custom error classes carry status and details, so one central handler can respond consistently.
- Always handle async errors, and add process-level guards as a last resort.
- Log with context, monitor in production, and never leak internals to clients.
π Further Reading
- Express β Error Handling
- Node.js Best Practices β Error Handling
- http-errors package
- Winston logging library
- Sentry for Express
π What's Next?
You've built a robust JavaScript backend β APIs, middleware, and error handling. Now the frontend calls: React Component Architecture begins building the user interface that will consume everything you've made.
π Excellent!
Your API now fails gracefully and tells you why. That's the mark of production-ready code β on to React.