Express.js Error Handling

Every application encounters errors: a database connection drops, a user sends invalid data, a file does not exist, or a bug appears in code. Without proper error handling, Express crashes or sends confusing responses. Good error handling catches these situations, logs them for developers, and sends clear, user-friendly messages to clients. This topic builds a complete error-handling system for Express applications.

What Happens Without Error Handling

Without error handling, an uncaught error in an async route crashes your server or sends an unhelpful HTML error page to API clients expecting JSON. Proper error handling creates a safety net that catches every failure gracefully.

Without error handling:               With error handling:
──────────────────────────            ──────────────────────────────
Error throws in route →               Error throws in route →
  Server crashes                        Error caught →
  OR                                    Log for developer →
  Express sends ugly HTML               Send clean JSON to client →
  error page to client                  Server keeps running

Synchronous Error Handling

Express automatically catches errors thrown synchronously inside route handlers. Use throw or pass the error to next():

app.get('/divide', (req, res) => {
  const { a, b } = req.query;

  if (b === '0') {
    const err = new Error('Cannot divide by zero');
    err.status = 400;
    throw err; // Express catches this automatically
  }

  res.json({ result: a / b });
});

Async Error Handling with next()

For async functions, errors do not automatically reach the error handler. You must catch them and pass them to next():

app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) {
      const err = new Error('User not found');
      err.status = 404;
      return next(err); // Pass error to Express error handler
    }
    res.json({ data: user });
  } catch (err) {
    next(err); // Database or unexpected errors
  }
});

Creating a Custom Error Class

A custom error class lets you attach extra information like HTTP status codes to errors:

class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.status = statusCode >= 400 && statusCode < 500 ? 'fail' : 'error';
    this.isOperational = true; // Marks expected errors (vs bugs)

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

module.exports = AppError;

Use it anywhere in your code:

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

app.get('/products/:id', async (req, res, next) => {
  try {
    const product = await Product.findById(req.params.id);
    if (!product) {
      return next(new AppError('Product not found', 404));
    }
    res.json({ data: product });
  } catch (err) {
    next(err);
  }
});

The Global Error Handler

Express recognizes a middleware with four parameters as an error handler. Place it after all routes:

// Must come AFTER all routes and other middleware
app.use((err, req, res, next) => {
  const statusCode = err.statusCode || 500;
  const status = err.status || 'error';

  console.error(`[${new Date().toISOString()}] ${err.message}`);
  console.error(err.stack);

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

Error Flow Through the Application

Request arrives
     │
     ▼
Middleware chain runs
     │
     ▼
Route handler runs ──── error occurs ──→ next(err) called
     │                                        │
     │ (no error)                             ▼
     ▼                              Error middleware runs:
res.json(data)                       (err, req, res, next)
     │                                        │
     ▼                                        ▼
Response sent ← ─────────────────── res.status().json()

Handling Specific Mongoose/MongoDB Errors

Database errors have specific names. Transform them into user-friendly messages:

app.use((err, req, res, next) => {
  let error = { ...err, message: err.message };

  // Invalid MongoDB ObjectId
  if (err.name === 'CastError') {
    error.message = `Resource not found with ID: ${err.value}`;
    error.statusCode = 404;
  }

  // Duplicate field value (unique constraint violation)
  if (err.code === 11000) {
    const field = Object.keys(err.keyValue)[0];
    error.message = `${field} already exists`;
    error.statusCode = 400;
  }

  // Mongoose validation error
  if (err.name === 'ValidationError') {
    const messages = Object.values(err.errors).map(e => e.message);
    error.message = messages.join(', ');
    error.statusCode = 400;
  }

  res.status(error.statusCode || 500).json({
    success: false,
    error: error.message || 'Server Error'
  });
});

A Helper Function to Wrap Async Routes

Writing try/catch in every async route is repetitive. A wrapper function handles it automatically:

// utils/catchAsync.js
const catchAsync = (fn) => {
  return (req, res, next) => {
    fn(req, res, next).catch(next);
  };
};

module.exports = catchAsync;

Now write cleaner routes without try/catch:

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

app.get('/users', catchAsync(async (req, res) => {
  const users = await User.find();
  res.json({ data: users });
  // Any error automatically passes to next() via catchAsync
}));

404 Handler for Undefined Routes

Add this before your global error handler to catch requests to undefined routes:

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

// Catches all routes that don't match any defined route
app.all('*', (req, res, next) => {
  next(new AppError(`Route ${req.originalUrl} not found`, 404));
});

// Global error handler comes after
app.use(globalErrorHandler);

Complete Error Handler Setup

// Correct order in app.js:

// 1. Middleware (body parsers, static files, etc.)
app.use(express.json());

// 2. Routes
app.use('/api/users', userRouter);
app.use('/api/products', productRouter);

// 3. 404 handler (after all routes, before error handler)
app.all('*', (req, res, next) => {
  next(new AppError(`${req.originalUrl} not found`, 404));
});

// 4. Global error handler (always last)
app.use((err, req, res, next) => {
  res.status(err.statusCode || 500).json({
    success: false,
    message: err.message
  });
});

Error Response Examples

┌──────────────────────────────────────────────────────────────┐
│               Error Response Examples                        │
├─────────────────────────┬────────────────────────────────────┤
│ Situation               │ Response                           │
├─────────────────────────┼────────────────────────────────────┤
│ Route not found         │ 404: "Route /api/xyz not found"    │
│ Invalid MongoDB ID      │ 404: "Resource not found"          │
│ Duplicate email         │ 400: "email already exists"        │
│ Validation failed       │ 400: "Title is required"           │
│ Unexpected server bug   │ 500: "Server Error"                │
└─────────────────────────┴────────────────────────────────────┘

Summary

Express catches synchronous errors thrown in route handlers automatically. For async routes, wrap code in try/catch and call next(err) to forward errors. The global error handler is a four-parameter middleware (err, req, res, next) that lives after all routes. Create a custom AppError class to attach status codes to errors. Handle specific error types like Mongoose CastErrors and duplicate key violations by inspecting the error name and code. Use a catchAsync wrapper to eliminate repetitive try/catch blocks. Add a catch-all route before the error handler to generate proper 404 responses for undefined routes.

Leave a Comment

Your email address will not be published. Required fields are marked *