Error Handling Middleware — Centralised Error Management

Every Express application has two guaranteed sources of failure: requests that don’t match any route, and errors that occur while processing requests that do match. Handling both cleanly — with consistent JSON responses, accurate HTTP status codes, and no stack traces leaking to the client — is a production requirement, not a nice-to-have. This lesson brings together everything from previous lessons to build the complete centralised error handling system for your MERN blog API: the AppError class, the asyncHandler wrapper, the 404 middleware, the global error middleware, and the specific handlers for every Mongoose and JWT error type you will encounter.

The Complete Error Handling Architecture

MERN API Error Handling — Four Components

1. AppError class
   └─ Custom Error subclass with statusCode
   └─ Thrown anywhere in controllers/middleware

2. asyncHandler wrapper (Express 4 only)
   └─ Wraps async route handlers
   └─ Catches rejected Promises → passes to next(err)

3. notFound middleware
   └─ Registered after all routes
   └─ Creates AppError(404) for unmatched requests → next(err)

4. errorHandler middleware (4 arguments)
   └─ Registered last of all
   └─ Handles ALL errors: AppError, Mongoose, JWT, unexpected
   └─ Sends consistent JSON response with correct status code
Note: The four-component architecture ensures that no error ever reaches the client unformatted. Mongoose CastErrors, JWT errors, validation errors, and your own AppErrors all flow into one place — the global errorHandler — which normalises them into the same JSON shape. Your React frontend never has to handle different error formats from different endpoints.
Tip: Distinguish between operational errors (expected failures: wrong password, post not found, validation failed) and programmer errors (unexpected bugs: undefined is not a function, database connection lost). Operational errors have an isOperational: true flag on AppError and should show a descriptive message to the client. Programmer errors should show a generic “Internal server error” message — never expose the real error message in production.
Warning: Always log errors on the server even when sending a generic message to the client. If a programmer error occurs in production and you only log “Internal server error” to the client without logging the full stack trace on the server, you will have no way to debug the problem. Use console.error(err.stack) in development and a structured logging service (Winston, Pino) in production.

Component 1 — AppError Class

// server/src/utils/AppError.js
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.name         = 'AppError';
    this.statusCode   = statusCode;
    this.status       = statusCode >= 400 && statusCode < 500 ? 'fail' : 'error';
    this.isOperational = true;  // known, expected error — safe to send message to client

    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = AppError;

// Usage examples:
throw new AppError('Post not found', 404);
throw new AppError('Title is required', 400);
throw new AppError('Not authorised to delete this post', 403);
throw new AppError('Email already registered', 409);

Component 2 — asyncHandler Wrapper

// server/src/utils/asyncHandler.js
// Wraps async route handlers to catch rejected promises in Express 4
// Not needed in Express 5 — but safe to use with both versions

const asyncHandler = fn => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

module.exports = asyncHandler;

// ── Without asyncHandler ──────────────────────────────────────────────────────
const getPost = async (req, res, next) => {
  try {
    const post = await Post.findById(req.params.id);
    if (!post) return next(new AppError('Not found', 404));
    res.json({ data: post });
  } catch (err) {
    next(err);
  }
};

// ── With asyncHandler — identical behaviour, cleaner syntax ───────────────────
const getPost = asyncHandler(async (req, res) => {
  const post = await Post.findById(req.params.id);
  if (!post) throw new AppError('Not found', 404);
  res.json({ data: post });
});

Component 3 — notFound Middleware

// server/src/middleware/notFound.js
const AppError = require('../utils/AppError');

const notFound = (req, res, next) => {
  next(new AppError(`Route not found: ${req.method} ${req.originalUrl}`, 404));
};

module.exports = notFound;

// Registration in index.js — AFTER all routes:
app.use('/api/posts', postsRouter);
app.use('/api/auth',  authRouter);

app.use(notFound);      // catches everything that didn't match a route
app.use(errorHandler);  // handles all errors including the 404 AppError

Component 4 — Global Error Handler

// server/src/middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
  // Always log the full error on the server
  console.error(`[${new Date().toISOString()}] ERROR:`, err.name, err.message);
  if (process.env.NODE_ENV === 'development') console.error(err.stack);

  // Start with defaults
  let statusCode  = err.statusCode || 500;
  let message     = err.message || 'Internal Server Error';
  let isOperational = err.isOperational || false;

  // ── Mongoose: invalid ObjectId ─────────────────────────────────────────────
  if (err.name === 'CastError' && err.kind === 'ObjectId') {
    statusCode    = 400;
    message       = `Invalid ID: '${err.value}' is not a valid resource identifier`;
    isOperational = true;
  }

  // ── Mongoose: schema validation failed ─────────────────────────────────────
  if (err.name === 'ValidationError') {
    statusCode    = 400;
    message       = Object.values(err.errors).map(e => e.message).join('; ');
    isOperational = true;
  }

  // ── MongoDB: duplicate key (e.g. unique email) ─────────────────────────────
  if (err.code === 11000) {
    const field   = Object.keys(err.keyValue || {})[0] || 'field';
    statusCode    = 409;
    message       = `${field.charAt(0).toUpperCase() + field.slice(1)} already exists`;
    isOperational = true;
  }

  // ── JWT: invalid token ─────────────────────────────────────────────────────
  if (err.name === 'JsonWebTokenError') {
    statusCode    = 401;
    message       = 'Invalid token — please log in again';
    isOperational = true;
  }

  // ── JWT: expired token ─────────────────────────────────────────────────────
  if (err.name === 'TokenExpiredError') {
    statusCode    = 401;
    message       = 'Your session has expired — please log in again';
    isOperational = true;
  }

  // ── Payload too large ─────────────────────────────────────────────────────
  if (err.type === 'entity.too.large') {
    statusCode    = 413;
    message       = 'Request body too large';
    isOperational = true;
  }

  // In production: hide internal error messages for non-operational errors
  if (process.env.NODE_ENV === 'production' && !isOperational) {
    message = 'An unexpected error occurred — our team has been notified';
  }

  res.status(statusCode).json({
    success: false,
    message,
    ...(process.env.NODE_ENV === 'development' && {
      errorType: err.name,
      stack:     err.stack,
    }),
  });
};

module.exports = errorHandler;

End-to-End Error Flow Examples

── Scenario 1: Invalid ObjectId ──────────────────────────────────────────────
GET /api/posts/not-an-id

1. asyncHandler wraps getPostById
2. await Post.findById('not-an-id')
3. Mongoose throws CastError: Cast to ObjectId failed
4. asyncHandler catches → next(castError)
5. errorHandler: name='CastError', kind='ObjectId'
   → statusCode=400, message='Invalid ID: not-an-id...'
Response: 400 { success: false, message: 'Invalid ID: not-an-id...' }

── Scenario 2: Duplicate email on register ───────────────────────────────────
POST /api/auth/register  { email: 'existing@email.com' }

1. await User.create({ email: 'existing@email.com', ... })
2. MongoDB throws MongoServerError code 11000 (duplicate key)
3. asyncHandler catches → next(mongoError)
4. errorHandler: err.code === 11000, keyValue = { email: ... }
   → statusCode=409, message='Email already exists'
Response: 409 { success: false, message: 'Email already exists' }

── Scenario 3: Route not found ───────────────────────────────────────────────
GET /api/nonexistent

1. No route matches — falls through all route handlers
2. notFound middleware: next(new AppError('Route not found: GET /api/nonexistent', 404))
3. errorHandler: err.statusCode=404, err.isOperational=true
   → message='Route not found: GET /api/nonexistent'
Response: 404 { success: false, message: 'Route not found: GET /api/nonexistent' }

Common Mistakes

Mistake 1 — Sending different error shapes from different routes

❌ Wrong — inconsistent error response shapes across endpoints:

// Route A: { error: 'Not found' }
// Route B: { message: 'Validation failed', errors: [...] }
// Route C: { status: 'error', msg: 'Unauthorised' }
// React has to handle three different shapes — fragile

✅ Correct — centralise all error responses through one errorHandler that always returns { success: false, message: '...' }.

Mistake 2 — Exposing stack traces in production

❌ Wrong — always sending the stack trace:

res.status(500).json({ message: err.message, stack: err.stack });
// Stack trace may reveal file paths, library versions, and internal logic

✅ Correct — include stack only in development:

res.status(statusCode).json({
  success: false,
  message,
  ...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
});

Mistake 3 — Not handling Mongoose validation errors centrally

❌ Wrong — catching and re-formatting Mongoose ValidationError in every controller:

try {
  await Post.create(data);
} catch (err) {
  if (err.name === 'ValidationError') {
    return res.status(400).json({ errors: err.errors }); // duplicated in 10 controllers
  }
  next(err);
}

✅ Correct — pass all errors to next() and handle Mongoose errors once in the global errorHandler.

Quick Reference

Error Type Detection Status
AppError (custom) err.isOperational === true err.statusCode
Invalid ObjectId err.name === 'CastError' 400
Schema validation err.name === 'ValidationError' 400
Duplicate key err.code === 11000 409
Invalid JWT err.name === 'JsonWebTokenError' 401
Expired JWT err.name === 'TokenExpiredError' 401
Body too large err.type === 'entity.too.large' 413
Unexpected error Everything else 500

🧠 Test Yourself

A user tries to register with an email that already exists in MongoDB. Mongoose throws a MongoServerError with code: 11000. Which response should your API return?