Handling 404 and Error Routes in Express

Two things are certain in every Express API: some requests will arrive for routes that do not exist, and some requests that do match a route will trigger errors โ€” database failures, validation problems, invalid IDs, or unexpected exceptions. Handling both cleanly โ€” with consistent JSON responses, correct HTTP status codes, and no unhandled promise rejections crashing your server โ€” is what distinguishes a production-ready API from a prototype. In this lesson you will build a complete Express error handling layer covering 404 catch-all handlers, global error middleware, custom error classes, and the async wrapper pattern.

Express Error Handling โ€” The Four Rules

Rule Detail
404 handler goes last Define the not-found handler after all routes โ€” it catches any request that did not match
Error middleware has 4 arguments (err, req, res, next) โ€” Express identifies it as error middleware by the extra parameter
Error middleware goes after everything Registered last with app.use() โ€” after all routes and the 404 handler
Pass errors with next(err) Call next(err) from any route or middleware to skip to the error handler
Note: In Express 5, errors thrown with throw inside async route handlers are automatically passed to your error middleware โ€” you do not need try/catch in every handler. In Express 4, an uncaught error inside an async function causes an unhandled promise rejection that does NOT trigger your error middleware. You must either use try/catch + next(err), or wrap handlers with an async wrapper utility.
Tip: Create a custom AppError class that extends the built-in Error and accepts a status code. Throw new AppError('Post not found', 404) anywhere in your controllers, and your global error middleware reads the statusCode from the error object to send the right HTTP response. This keeps status code logic out of your controllers entirely.
Warning: Never call next(err) with a non-Error value such as a string or plain object. Express expects an actual Error instance (or subclass). If you pass next('something went wrong'), Express may not handle it correctly across all versions. Always: next(new Error('message')) or next(new AppError('message', 400)).

The 404 Not Found Handler

// server/src/middleware/notFound.js
const notFound = (req, res, next) => {
  const error = new Error(`Route not found: ${req.method} ${req.originalUrl}`);
  error.statusCode = 404;
  next(error); // pass to the global error handler
};

module.exports = notFound;

// In index.js โ€” registered AFTER all routes:
app.use('/api/posts', require('./src/routes/posts'));
app.use('/api/auth',  require('./src/routes/auth'));

app.use(notFound);       // 404 catch-all โ† after all routes
app.use(errorHandler);   // global error handler โ† very last

Global Error Middleware

// server/src/middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
  // Log the full error server-side (never expose stack traces to clients)
  console.error(`[ERROR] ${err.name}: ${err.message}`);
  if (process.env.NODE_ENV === 'development') {
    console.error(err.stack);
  }

  // Default status code and message
  let statusCode = err.statusCode || err.status || 500;
  let message    = err.message    || 'Internal Server Error';

  // โ”€โ”€ Handle specific Mongoose / MongoDB error types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

  // CastError: invalid MongoDB ObjectId format
  if (err.name === 'CastError' && err.kind === 'ObjectId') {
    statusCode = 400;
    message    = `Invalid ID format: ${err.value}`;
  }

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

  // MongoServerError code 11000: duplicate key (e.g. email already exists)
  if (err.code === 11000) {
    const field = Object.keys(err.keyValue)[0];
    statusCode  = 409;
    message     = `${field} already exists`;
  }

  // JsonWebTokenError: invalid JWT
  if (err.name === 'JsonWebTokenError') {
    statusCode = 401;
    message    = 'Invalid token';
  }

  // TokenExpiredError: expired JWT
  if (err.name === 'TokenExpiredError') {
    statusCode = 401;
    message    = 'Token expired โ€” please log in again';
  }

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

module.exports = errorHandler;

Custom AppError Class

// server/src/utils/AppError.js
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode  = statusCode;
    this.status      = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    this.isOperational = true; // distinguishes known errors from unexpected bugs
    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = AppError;

// Usage anywhere in controllers:
const AppError = require('../utils/AppError');

if (!post) throw new AppError('Post not found', 404);
if (!user) throw new AppError('User not found', 404);
if (post.author.toString() !== req.user.id) throw new AppError('Not authorised', 403);

The Async Wrapper (Express 4 Only)

// server/src/utils/asyncHandler.js
// Wraps an async function so thrown errors are passed to next() automatically
// Not needed in Express 5 โ€” but essential for Express 4

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

module.exports = asyncHandler;

// โ”€โ”€ Without asyncHandler (Express 4) โ€” verbose โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const getPostById = async (req, res, next) => {
  try {
    const post = await Post.findById(req.params.id);
    if (!post) return next(new AppError('Post not found', 404));
    res.json({ success: true, data: post });
  } catch (err) {
    next(err); // must manually catch and forward every error
  }
};

// โ”€โ”€ With asyncHandler (Express 4) โ€” clean โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const asyncHandler = require('../utils/asyncHandler');

const getPostById = asyncHandler(async (req, res) => {
  const post = await Post.findById(req.params.id);
  if (!post) throw new AppError('Post not found', 404); // throw instead of next()
  res.json({ success: true, data: post });
}); // errors automatically forwarded to global error handler โœ“

Complete Error Flow โ€” End to End

Request: GET /api/posts/invalid-id-format

1.  Express matches route: GET /api/posts/:id โ†’ getPostById handler
2.  getPostById: await Post.findById('invalid-id-format')
3.  Mongoose throws CastError: Cast to ObjectId failed for value "invalid-id-format"
4.  asyncHandler catches the error โ†’ calls next(castError)
5.  Express skips all remaining route/middleware โ†’ jumps to errorHandler (4 args)
6.  errorHandler: err.name === 'CastError' && err.kind === 'ObjectId'
    โ†’ statusCode = 400, message = 'Invalid ID format: invalid-id-format'
7.  Response sent: 400  { "success": false, "message": "Invalid ID format: invalid-id-format" }

Request: GET /api/posts/nonexistent-but-valid-objectid

1.  Express matches route โ†’ getPostById handler
2.  await Post.findById(validId) โ†’ returns null (document not found)
3.  throw new AppError('Post not found', 404) โ†’ next(appError) via asyncHandler
4.  errorHandler: err.statusCode = 404
    โ†’ message = 'Post not found'
5.  Response sent: 404  { "success": false, "message": "Post not found" }

Common Mistakes

Mistake 1 โ€” Error middleware with only 3 parameters

โŒ Wrong โ€” Express does not recognise it as error middleware:

app.use((err, res, next) => { // missing req โ€” Express sees 3 params โ†’ not error middleware!
  res.status(500).json({ message: err.message });
});

โœ… Correct โ€” error middleware must have exactly 4 parameters, even if you do not use all of them:

app.use((err, req, res, next) => { // 4 params โ†’ Express identifies as error middleware โœ“
  res.status(500).json({ message: err.message });
});

Mistake 2 โ€” Registering error middleware before routes

โŒ Wrong โ€” error middleware defined before routes never receives errors from those routes:

app.use(errorHandler);            // registered first โ€” too early
app.use('/api/posts', postsRouter); // errors here bypass errorHandler

โœ… Correct โ€” error middleware is always last:

app.use('/api/posts', postsRouter);
app.use(notFound);
app.use(errorHandler); // always last โœ“

Mistake 3 โ€” Throwing errors without using next() in Express 4

โŒ Wrong โ€” throwing inside an async route without asyncHandler causes an unhandled rejection:

app.get('/api/posts/:id', async (req, res) => {
  const post = await Post.findById(req.params.id);
  if (!post) throw new Error('Not found'); // unhandled rejection in Express 4 โ†’ crash
});

โœ… Correct โ€” wrap with asyncHandler or use try/catch + next(err):

app.get('/api/posts/:id', asyncHandler(async (req, res) => {
  const post = await Post.findById(req.params.id);
  if (!post) throw new AppError('Not found', 404); // safely caught โœ“
}));

Quick Reference

Task Code
404 handler app.use((req,res,next) => next(new AppError('Not found',404)))
Global error middleware app.use((err,req,res,next) => res.status(err.statusCode||500).json(...))
Throw known error throw new AppError('Message', 404)
Forward to error handler next(new AppError('Message', 400))
Wrap async handler (Exp 4) asyncHandler(async (req,res) => { ... })
Check error type err.name === 'CastError', err.code === 11000
Registration order routes โ†’ notFound โ†’ errorHandler

🧠 Test Yourself

You define a global error middleware as app.use((err, req, res, next) => { ... }) but errors from your route handlers are not reaching it. What is the most likely cause?