Episode 3 — NodeJS MongoDB Backend Architecture / 3.17 — Error Handling in Express

3.17.b — Centralized Error Handling

Instead of scattering error-response logic across every route handler, a centralized error handler catches all errors in one place, formats them consistently, and handles Mongoose, JWT, and custom errors with appropriate status codes and messages.


<< Previous: 3.17.a — Error Handling Basics | Next: 3.17.c — Async Handler Utility >>


1. Error-Handling Middleware: The Four-Parameter Signature

Express identifies error-handling middleware by its four parameters. This is not optional -- if you omit one parameter, Express treats it as a regular middleware and will never call it with errors.

// CORRECT: Error-handling middleware (4 parameters)
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ message: 'Something went wrong' });
});

// WRONG: Only 3 parameters — Express treats this as regular middleware
app.use((err, req, res) => {
  // This will NEVER be called as an error handler!
  // Express sees 3 params and treats it as (req, res, next)
});

Must Be Registered AFTER All Routes

Error-handling middleware must come after all your routes and regular middleware. Express processes middleware in order, and errors need to "fall through" to reach the error handler.

const express = require('express');
const app = express();

// 1. Body parser middleware
app.use(express.json());

// 2. Regular middleware
app.use(authMiddleware);

// 3. All your routes
app.use('/api/users', userRoutes);
app.use('/api/products', productRoutes);
app.use('/api/orders', orderRoutes);

// 4. 404 handler (for unmatched routes)
app.all('*', (req, res, next) => {
  next(new ApiError(404, `Route ${req.originalUrl} not found`));
});

// 5. Error handler — MUST be LAST
app.use(errorHandler);
MIDDLEWARE ORDER

  express.json()          ← Parse body
  authMiddleware          ← Check auth
  /api/users routes       ← Handle requests
  /api/products routes
  404 handler             ← Catch unmatched routes
  errorHandler            ← Catch ALL errors (must be last)

2. Creating the ApiError Custom Error Class

JavaScript's built-in Error class only has message and stack. For HTTP APIs, you need more information: the HTTP status code, whether the error is operational (expected), and possibly additional details.

// utils/ApiError.js

class ApiError extends Error {
  /**
   * Create an API error.
   * @param {number} statusCode - HTTP status code (400, 401, 404, 500, etc.)
   * @param {string} message    - Human-readable error message
   * @param {boolean} isOperational - True if expected/operational, false if bug
   * @param {string} stack      - Optional custom stack trace
   */
  constructor(statusCode, message, isOperational = true, stack = '') {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = isOperational;
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';

    if (stack) {
      this.stack = stack;
    } else {
      Error.captureStackTrace(this, this.constructor);
    }
  }
}

module.exports = ApiError;

Using ApiError

const ApiError = require('../utils/ApiError');

// In a route handler or service layer:
if (!user) {
  throw new ApiError(404, 'User not found');
}

if (!req.body.email) {
  throw new ApiError(400, 'Email is required');
}

if (user.role !== 'admin') {
  throw new ApiError(403, 'Only admins can access this resource');
}

// For unexpected/programmer errors:
throw new ApiError(500, 'Database connection failed', false);
// isOperational = false → this is a bug, not an expected condition

status vs statusCode

PropertyValueMeaning
statusCode404The numeric HTTP status code
status'fail'4xx errors = 'fail' (client error)
status'error'5xx errors = 'error' (server error)
isOperationaltrueExpected error (bad input, not found)
isOperationalfalseProgrammer error (bug, crash)

3. The Centralized Error Handler Function

The error handler inspects the error object, logs it, and sends a formatted response. It behaves differently in development and production.

// middleware/errorHandler.js

const ApiError = require('../utils/ApiError');

const errorHandler = (err, req, res, next) => {
  // Default values
  err.statusCode = err.statusCode || 500;
  err.message = err.message || 'Internal Server Error';

  // Log the error
  if (err.statusCode >= 500) {
    console.error('ERROR:', {
      message: err.message,
      stack: err.stack,
      url: req.originalUrl,
      method: req.method,
      ip: req.ip,
      timestamp: new Date().toISOString(),
    });
  }

  // Development: send full error details including stack trace
  if (process.env.NODE_ENV === 'development') {
    return res.status(err.statusCode).json({
      success: false,
      status: err.status || 'error',
      message: err.message,
      stack: err.stack,
      error: err,
    });
  }

  // Production: send clean error message, no stack trace
  if (err.isOperational) {
    // Operational errors: safe to send the message to the client
    return res.status(err.statusCode).json({
      success: false,
      status: err.status || 'error',
      message: err.message,
    });
  }

  // Programmer errors in production: do not leak details
  return res.status(500).json({
    success: false,
    status: 'error',
    message: 'Something went wrong',
  });
};

module.exports = errorHandler;

Development vs Production Responses

DEVELOPMENT RESPONSE (full details for debugging):
{
  "success": false,
  "status": "fail",
  "message": "User not found",
  "stack": "ApiError: User not found\n    at /app/controllers/user.js:25:11\n...",
  "error": { "statusCode": 404, "isOperational": true }
}

PRODUCTION RESPONSE — OPERATIONAL ERROR (safe message):
{
  "success": false,
  "status": "fail",
  "message": "User not found"
}

PRODUCTION RESPONSE — PROGRAMMER ERROR (no details leaked):
{
  "success": false,
  "status": "error",
  "message": "Something went wrong"
}

4. Handling Specific Error Types

Different libraries throw different error types. Your centralized handler should convert these into consistent ApiError instances.

Mongoose CastError

Thrown when an invalid value is used where a MongoDB ObjectId is expected.

// Example trigger: GET /api/users/not-a-valid-id
// Mongoose throws: CastError: Cast to ObjectId failed for value "not-a-valid-id"

const handleCastError = (err) => {
  const message = `Invalid ${err.path}: ${err.value}`;
  return new ApiError(400, message);
};

Mongoose ValidationError

Thrown when a document fails schema validation. Contains nested errors for each invalid field.

// Example trigger: POST /api/users with { name: '' } when name is required
// Mongoose throws: ValidationError with errors.name, errors.email, etc.

const handleValidationError = (err) => {
  const errors = Object.values(err.errors).map((e) => e.message);
  const message = `Validation failed: ${errors.join('. ')}`;
  return new ApiError(400, message);
};

MongoDB Duplicate Key Error (code 11000)

Thrown when a unique index constraint is violated (e.g., duplicate email).

// Example trigger: POST /api/users with an email that already exists
// MongoDB throws: MongoServerError with code 11000

const handleDuplicateKeyError = (err) => {
  const field = Object.keys(err.keyValue)[0];
  const value = err.keyValue[field];
  const message = `Duplicate value '${value}' for field '${field}'. Please use another value.`;
  return new ApiError(409, message);
};

JWT Errors

// JsonWebTokenError: malformed token, invalid signature
const handleJWTError = () =>
  new ApiError(401, 'Invalid token. Please log in again.');

// TokenExpiredError: token past its expiration date
const handleJWTExpiredError = () =>
  new ApiError(401, 'Your token has expired. Please log in again.');

Error Type Summary

Error TypeSourceStatus CodeUser Message
CastErrorMongoose400"Invalid {field}: {value}"
ValidationErrorMongoose400"Validation failed: field messages"
Code 11000MongoDB409"Duplicate value for field"
JsonWebTokenErrorjsonwebtoken401"Invalid token"
TokenExpiredErrorjsonwebtoken401"Token expired"
SyntaxError (body parser)Express400"Invalid JSON in request body"

5. Enhanced Error Handler with Type Detection

Combine all the specific error converters into one comprehensive error handler:

// middleware/errorHandler.js

const ApiError = require('../utils/ApiError');

const handleCastError = (err) => {
  const message = `Invalid ${err.path}: ${err.value}`;
  return new ApiError(400, message);
};

const handleValidationError = (err) => {
  const errors = Object.values(err.errors).map((e) => e.message);
  const message = `Validation failed: ${errors.join('. ')}`;
  return new ApiError(400, message);
};

const handleDuplicateKeyError = (err) => {
  const field = Object.keys(err.keyValue)[0];
  const value = err.keyValue[field];
  const message = `Duplicate value '${value}' for field '${field}'. Please use another value.`;
  return new ApiError(409, message);
};

const handleJWTError = () =>
  new ApiError(401, 'Invalid token. Please log in again.');

const handleJWTExpiredError = () =>
  new ApiError(401, 'Your token has expired. Please log in again.');

const handleSyntaxError = () =>
  new ApiError(400, 'Invalid JSON in request body');

const errorHandler = (err, req, res, next) => {
  // Clone the error to avoid mutating the original
  let error = { ...err, message: err.message, stack: err.stack };

  // Convert known error types to ApiError
  if (err.name === 'CastError') error = handleCastError(err);
  if (err.name === 'ValidationError') error = handleValidationError(err);
  if (err.code === 11000) error = handleDuplicateKeyError(err);
  if (err.name === 'JsonWebTokenError') error = handleJWTError();
  if (err.name === 'TokenExpiredError') error = handleJWTExpiredError();
  if (err.name === 'SyntaxError' && err.status === 400) error = handleSyntaxError();

  // Set defaults
  error.statusCode = error.statusCode || 500;
  error.status = error.status || 'error';

  // Log server errors
  if (error.statusCode >= 500) {
    console.error('SERVER ERROR:', {
      message: error.message,
      stack: err.stack,
      url: req.originalUrl,
      method: req.method,
      body: req.body,
      timestamp: new Date().toISOString(),
    });
  }

  // Development response: full details
  if (process.env.NODE_ENV === 'development') {
    return res.status(error.statusCode).json({
      success: false,
      status: error.status,
      message: error.message,
      stack: err.stack,
      error: err,
    });
  }

  // Production response: operational errors show message, others are generic
  if (error.isOperational) {
    return res.status(error.statusCode).json({
      success: false,
      status: error.status,
      message: error.message,
    });
  }

  return res.status(500).json({
    success: false,
    status: 'error',
    message: 'Something went wrong',
  });
};

module.exports = errorHandler;

6. 404 Not Found Handler for Unknown Routes

Any request that does not match a defined route should receive a 404 response. Place this after all route definitions but before the error handler.

// Place after all routes, before errorHandler
app.all('*', (req, res, next) => {
  next(new ApiError(404, `Cannot find ${req.originalUrl} on this server`));
});

Why app.all('*') Instead of app.use()?

ApproachBehavior
app.all('*', handler)Matches ALL HTTP methods (GET, POST, PUT, DELETE, PATCH, etc.) for any path
app.use(handler)Also works, but app.all('*') is more explicit about intent
// Alternative: using app.use
app.use((req, res, next) => {
  next(new ApiError(404, `Cannot find ${req.originalUrl} on this server`));
});
// Both work. app.all('*') is the more common convention.

7. Complete Working Example

Here is a full Express application with all error handling pieces assembled:

// ----- utils/ApiError.js -----

class ApiError extends Error {
  constructor(statusCode, message, isOperational = true, stack = '') {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = isOperational;
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    if (stack) {
      this.stack = stack;
    } else {
      Error.captureStackTrace(this, this.constructor);
    }
  }
}

module.exports = ApiError;


// ----- middleware/errorHandler.js -----

const ApiError = require('../utils/ApiError');

const handleCastError = (err) =>
  new ApiError(400, `Invalid ${err.path}: ${err.value}`);

const handleValidationError = (err) => {
  const messages = Object.values(err.errors).map((e) => e.message);
  return new ApiError(400, `Validation failed: ${messages.join('. ')}`);
};

const handleDuplicateKeyError = (err) => {
  const field = Object.keys(err.keyValue)[0];
  return new ApiError(409, `Duplicate value for '${field}'. Please use another value.`);
};

const handleJWTError = () =>
  new ApiError(401, 'Invalid token. Please log in again.');

const handleJWTExpiredError = () =>
  new ApiError(401, 'Your token has expired. Please log in again.');

const errorHandler = (err, req, res, next) => {
  let error = { ...err, message: err.message, stack: err.stack };

  if (err.name === 'CastError') error = handleCastError(err);
  if (err.name === 'ValidationError') error = handleValidationError(err);
  if (err.code === 11000) error = handleDuplicateKeyError(err);
  if (err.name === 'JsonWebTokenError') error = handleJWTError();
  if (err.name === 'TokenExpiredError') error = handleJWTExpiredError();

  error.statusCode = error.statusCode || 500;

  if (error.statusCode >= 500) {
    console.error('ERROR:', err.stack);
  }

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

  if (error.isOperational) {
    return res.status(error.statusCode).json({
      success: false,
      message: error.message,
    });
  }

  return res.status(500).json({
    success: false,
    message: 'Something went wrong',
  });
};

module.exports = errorHandler;


// ----- app.js -----

const express = require('express');
const mongoose = require('mongoose');
const ApiError = require('./utils/ApiError');
const errorHandler = require('./middleware/errorHandler');

const app = express();

// Body parser
app.use(express.json({ limit: '10kb' }));

// Routes
app.get('/api/health', (req, res) => {
  res.json({ status: 'OK', timestamp: new Date().toISOString() });
});

app.get('/api/users/:id', async (req, res, next) => {
  try {
    const user = await mongoose.model('User').findById(req.params.id);
    if (!user) {
      return next(new ApiError(404, 'User not found'));
    }
    res.json({ success: true, data: user });
  } catch (err) {
    next(err);
  }
});

app.post('/api/users', async (req, res, next) => {
  try {
    const user = await mongoose.model('User').create(req.body);
    res.status(201).json({ success: true, data: user });
  } catch (err) {
    next(err); // Mongoose ValidationError or duplicate key → handled by errorHandler
  }
});

// 404 handler for undefined routes
app.all('*', (req, res, next) => {
  next(new ApiError(404, `Cannot find ${req.originalUrl} on this server`));
});

// Centralized error handler (MUST be last middleware)
app.use(errorHandler);

module.exports = app;


// ----- server.js -----

process.on('uncaughtException', (err) => {
  console.error('UNCAUGHT EXCEPTION! Shutting down...');
  console.error(err.name, err.message);
  process.exit(1);
});

const app = require('./app');

const PORT = process.env.PORT || 3000;
const server = app.listen(PORT, () => {
  console.log(`Server running in ${process.env.NODE_ENV} mode on port ${PORT}`);
});

process.on('unhandledRejection', (reason) => {
  console.error('UNHANDLED REJECTION! Shutting down...');
  console.error(reason);
  server.close(() => process.exit(1));
});

Testing the Error Handler

# Health check (200)
curl http://localhost:3000/api/health

# Valid user (200) or not found (404)
curl http://localhost:3000/api/users/507f1f77bcf86cd799439011

# Invalid ID format — triggers CastError (400)
curl http://localhost:3000/api/users/invalid-id

# Undefined route — triggers 404 handler
curl http://localhost:3000/api/nonexistent

# Missing required fields — triggers ValidationError (400)
curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{}'

# Duplicate key — triggers 11000 error (409)
curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"email": "existing@email.com"}'

# Malformed JSON — triggers SyntaxError (400)
curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{invalid json}'

8. Error Handler Design Decisions

Should You Use res.status().json() or res.status().send()?

MethodUse Case
res.json()API responses — always use for REST APIs
res.send()HTML responses or generic content
res.render()Server-rendered HTML with a template engine

For REST APIs, always use res.json() so clients can reliably parse the response.

Should the Error Handler Call next()?

// Usually NO — the error handler is the final stop
app.use((err, req, res, next) => {
  res.status(err.statusCode || 500).json({ message: err.message });
  // Do NOT call next() — this is the end of the chain
});

// Exception: if you have MULTIPLE error handlers in sequence
app.use(logErrorHandler);       // Log the error, then next(err)
app.use(sendErrorResponse);     // Send the response

Multiple Error Handlers

You can chain error-handling middleware. Each receives the error, processes it, and either responds or passes it along with next(err).

// Error logger — logs and passes the error along
const logErrors = (err, req, res, next) => {
  console.error(err.stack);
  next(err); // Pass to the next error handler
};

// Error responder — sends the HTTP response
const sendErrorResponse = (err, req, res, next) => {
  res.status(err.statusCode || 500).json({
    success: false,
    message: err.message,
  });
};

app.use(logErrors);
app.use(sendErrorResponse);

Key Takeaways

  1. Error-handling middleware must have exactly 4 parameters: (err, req, res, next) -- this is how Express identifies it
  2. The error handler must be registered after all routes using app.use(errorHandler)
  3. The ApiError class extends Error and adds statusCode, isOperational, and status for clean API responses
  4. Development responses include the full stack trace; production responses hide internal details
  5. Handle specific error types: Mongoose CastError (400), ValidationError (400), duplicate key 11000 (409), JWT errors (401)
  6. Use app.all('*') before the error handler to catch requests to undefined routes (404)
  7. The isOperational flag distinguishes expected errors (safe to show message) from bugs (show generic message in production)

Explain-It Challenge

Scenario: Your API has the following user registration flow: the user sends POST /api/register with { name, email, password }. List every possible error that could occur during this request (think about: JSON parsing, validation, database operations, hashing). For each error, specify which error type it would be (Mongoose ValidationError, CastError, duplicate key, etc.), what status code you would return, and what message the user should see. Then show how each would be handled by the centralized error handler without any extra code in the route handler itself.


<< Previous: 3.17.a — Error Handling Basics | Next: 3.17.c — Async Handler Utility >>