Skip to main content

πŸ›‘οΈ 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 Express error flow A request passes through normal middleware to a route handler; a thrown error is diverted to error-handling middleware, which produces an error response, while success produces a normal response. Request Middleware Route handler Error middleware (err, req, res, next) Error response Success response next(err) ok
Figure 1 β€” A thrown or forwarded error diverts the request out of the normal flow and into the error-handling middleware.
πŸŽͺ 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

CodeNameUse when…
400Bad RequestThe client sent malformed or invalid data
401UnauthorizedAuthentication is missing or invalid
403ForbiddenAuthenticated, but not permitted
404Not FoundThe resource doesn't exist
409ConflictRequest conflicts with current state (e.g. duplicate email)
422Unprocessable EntityWell-formed but semantically invalid (validation)
429Too Many RequestsRate limit exceeded
500Internal Server ErrorAn unexpected error on the server
503Service UnavailableServer 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:

  1. Create ApiError (base) plus NotFoundError (404) and BadRequestError (400) that extend it.
  2. Write one error-handling middleware that returns { success:false, error:{ message, details? } } and uses err.statusCode || 500.
  3. Add a route GET /api/items/:id that throws NotFoundError('Item') when the id isn't 1.
  4. Add a 404 catch-all and register the error handler last.
  5. 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 statusCode and details
  • 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

πŸš€ 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.