TL;DR: Error middleware is the safety net that catches crashes in your Express app. Instead of your server dying when something breaks, error middleware catches the problem, sends a clean error response, and keeps everything running.

Why AI Coders Need to Know This

When you ask AI to build an Express API, it scatters try/catch blocks throughout your route handlers. Each route handles its own errors independently — some return JSON, some return plain text, some forget to send a response at all. The app works fine in development when you're testing happy paths.

Then you deploy to production. A user sends unexpected data. A database connection drops. An external API times out. And your server crashes because there's no centralized place to catch those errors.

AI generates try/catch blocks everywhere but often doesn't set up centralized error handling. Error middleware is the difference between your app going down at 2 AM and your app logging the problem, sending a clean "something went wrong" response, and staying online. It's not glamorous code, but it's the code that saves you when everything else fails.

The Mental Model: A Hospital Triage System

Imagine a hospital without a triage system. Every doctor handles emergencies in their own exam room — some call 911, some grab equipment from random closets, some panic and freeze. It's chaos. Patients fall through the cracks.

Now imagine a hospital with triage. No matter where a problem happens — the waiting room, an exam room, the parking lot — the emergency goes to one central place. Triage assesses the severity, routes the patient appropriately, and notifies the right people. Every doctor can focus on their job because they know the system will catch emergencies.

Error middleware is your app's triage system. Instead of every route handler dealing with errors ad hoc, there's one central place that catches all problems, logs them, sends the right response, and keeps the app running. Your route handlers can focus on what they do — handle requests — and trust that if something goes wrong, the safety net is there.

Real Scenario

Prompt I Would Type

Build me an Express API with proper error handling — I want 
clean error responses in production, detailed logs in 
development, and the app should never crash from unhandled 
errors

This is the kind of prompt that gets you production-ready error handling instead of the scattered try/catch approach. Here's what a well-configured AI will generate — and then we'll break down every piece.

What AI Generated

Step 1: Custom Error Class

// 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;

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

module.exports = AppError;

This gives you a custom error class that carries an HTTP status code with it. When you throw new AppError('User not found', 404), the error knows what HTTP status to send — instead of everything defaulting to 500.

Step 2: Async Error Wrapper

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

module.exports = catchAsync;

This tiny function is deceptively important. It wraps your async route handlers so that any rejected promise automatically gets passed to next() — which sends it to your error middleware. Without this, async errors crash your server silently.

Step 3: Route Handlers Using the Wrapper

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

// This route is wrapped with catchAsync — any error automatically
// goes to error middleware instead of crashing the server
app.get('/api/users/:id', catchAsync(async (req, res, next) => {
  const user = await db.findUser(req.params.id);

  if (!user) {
    // This creates a 404 error and sends it to error middleware
    return next(new AppError('User not found', 404));
  }

  res.json({ status: 'success', data: { user } });
}));

app.post('/api/users', catchAsync(async (req, res, next) => {
  const newUser = await db.createUser(req.body);
  res.status(201).json({ status: 'success', data: { user: newUser } });
  // If db.createUser throws (duplicate email, validation error, etc.),
  // catchAsync automatically catches it and sends it to error middleware
}));

Step 4: The 404 Handler

// Catch any request that didn't match a route
// This must go AFTER all your routes but BEFORE the error middleware
app.all('*', (req, res, next) => {
  next(new AppError(`Can't find ${req.originalUrl} on this server`, 404));
});

This catches any request to a URL that doesn't match any of your routes. Instead of Express sending a default HTML "Cannot GET /whatever" page, it creates a proper 404 error and sends it to your error middleware for clean handling.

Step 5: Global Error Middleware

// middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
  err.statusCode = err.statusCode || 500;
  err.status = err.status || 'error';

  if (process.env.NODE_ENV === 'development') {
    // In development: show everything for debugging
    res.status(err.statusCode).json({
      status: err.status,
      error: err,
      message: err.message,
      stack: err.stack
    });
  } else {
    // In production: only show clean, safe messages
    if (err.isOperational) {
      // Operational errors: safe to show the message to users
      res.status(err.statusCode).json({
        status: err.status,
        message: err.message
      });
    } else {
      // Programming errors: don't leak details
      console.error('ERROR 💥', err);
      res.status(500).json({
        status: 'error',
        message: 'Something went wrong'
      });
    }
  }
};

// Register it LAST — after all routes and the 404 handler
app.use(errorHandler);

Step 6: Process-Level Safety Nets

// Catch unhandled promise rejections (e.g., failed DB connection)
process.on('unhandledRejection', (err) => {
  console.error('UNHANDLED REJECTION 💥', err.name, err.message);
  // Gracefully shut down — finish current requests, then exit
  server.close(() => {
    process.exit(1);
  });
});

// Catch uncaught exceptions (synchronous errors that weren't caught)
process.on('uncaughtException', (err) => {
  console.error('UNCAUGHT EXCEPTION 💥', err.name, err.message);
  process.exit(1);  // Must exit — the app is in an unknown state
});

These are the absolute last line of defense. If an error somehow escapes everything else — no try/catch caught it, no middleware handled it — these process-level handlers log the error and shut down the server gracefully instead of leaving it in a broken state.

Understanding Each Part

The 4-Input Rule

Error middleware needs exactly four inputs: (err, req, res, next). That's how Express tells the difference between regular middleware and error-handling middleware. If your error handler only has three inputs, Express doesn't know it's supposed to catch errors — and your errors fly right past it. This is the #1 reason error handling "doesn't work" in AI-generated code. Even if you don't use the fourth input, it has to be there.

What "next(err)" Does for You

When something goes wrong in a route, calling next(new AppError('Not found', 404)) is like pulling the emergency brake. It tells Express: "stop processing this request normally and send it straight to the error handler." That's all you need to know — when you see next(err) in your code, it means "something went wrong, hand it off to the safety net."

Why AI Adds That catchAsync Wrapper

Express 4 has a blind spot: if an async function throws an error, Express doesn't catch it. Your server just... hangs. The request times out and the user gets nothing. The catchAsync wrapper that AI generates fixes this by making sure async errors get sent to your error middleware instead of disappearing. If you see catchAsync in your code, don't delete it — it's there to prevent silent crashes.

Expected Errors vs. Bugs

Your error middleware treats two kinds of errors differently:

  • Expected errors — things that can reasonably go wrong: user not found, bad input, expired login. Your app shows the actual error message because you wrote it and it's safe to share.
  • Bugs — things that shouldn't happen: typos in database queries, missing files, undefined variables. Your app hides the details and shows a generic "Something went wrong" because the real error message might leak your code structure to the outside world.

That isOperational flag in the code? It's just the label that tells the error middleware which kind of error it's dealing with. You don't need to memorize the implementation — just know that your safety net is smarter than "send 500 for everything."

What AI Gets Wrong About Error Middleware

1. Forgetting the 4th Parameter

This is the number one mistake. AI generates an error handler like this:

// ❌ BROKEN — only 3 parameters, Express treats this as regular middleware
app.use((err, req, res) => {
  res.status(500).json({ error: err.message });
});

Without the fourth parameter (next), Express doesn't recognize this as error middleware. Errors fly right past it. The fix is simple — always include all four parameters:

// ✅ CORRECT — 4 parameters tells Express this is error middleware
app.use((err, req, res, next) => {
  res.status(500).json({ error: err.message });
});

2. Not Using Async Wrappers

AI loves async/await but often forgets that Express 4 doesn't catch async errors automatically. You'll see this pattern everywhere:

// ❌ DANGEROUS — if db.findAll() throws, Express never catches it
app.get('/api/items', async (req, res) => {
  const items = await db.findAll();
  res.json(items);
});

If db.findAll() throws an error, the promise rejects, but Express doesn't know about it. The request hangs until it times out. The fix is the catchAsync wrapper or manual try/catch:

// ✅ SAFE — catchAsync sends rejected promises to error middleware
app.get('/api/items', catchAsync(async (req, res) => {
  const items = await db.findAll();
  res.json(items);
}));

3. Leaking Stack Traces in Production

AI-generated error handlers frequently send the full error stack in every response:

// ❌ SECURITY RISK — exposes internal code structure to anyone
app.use((err, req, res, next) => {
  res.status(500).json({
    error: err.message,
    stack: err.stack  // Shows file paths, line numbers, library versions
  });
});

In development, stack traces are your best friend for debugging. In production, they're a security risk — they reveal your file structure, which libraries you use, and exactly where your code breaks. Always check the environment:

// ✅ SAFE — stack traces only in development
app.use((err, req, res, next) => {
  const response = {
    status: 'error',
    message: err.isOperational ? err.message : 'Something went wrong'
  };

  if (process.env.NODE_ENV === 'development') {
    response.stack = err.stack;
  }

  res.status(err.statusCode || 500).json(response);
});

4. Not Handling unhandledRejection

Even with perfect error middleware, some errors escape Express entirely — like a database connection failing during startup, or a background job throwing an error. AI rarely adds the process.on('unhandledRejection') and process.on('uncaughtException') handlers. Without them, your Node.js process crashes silently with no log, no cleanup, and no graceful shutdown. These process-level handlers are your absolute last safety net — always include them.

How to Debug Error Middleware with AI

In Cursor

When errors aren't being caught properly, highlight your error middleware and ask: "Why isn't this error middleware catching my 404 errors? Check the parameter count, the registration order, and whether it's placed after all routes." Cursor's inline context means it can see your entire server file and trace where the error flow breaks down.

Another great prompt: "Add proper error handling to all my async routes — wrap them with catchAsync and make sure errors flow to my global error handler." This gets Cursor to retrofit proper error handling across your entire codebase in one pass.

In Windsurf

Use Cascade for cross-file error flow analysis: "Trace the error flow in this Express app — show me every place an error can be thrown and verify it reaches the global error handler in errorHandler.js." Windsurf excels at multi-file analysis, so it can follow errors across your routes, middleware, and utility files to find gaps in your error handling chain.

In Claude Code

Paste your error middleware along with a route that's misbehaving and ask: "This async route crashes my server instead of returning a 500 error. Review the error flow — is catchAsync wrapping it properly? Is the error middleware registered in the right order? Does it have 4 parameters?" Claude Code is excellent at catching the subtle issues — wrong parameter counts, missing returns, and misplaced middleware registration.

The Quick Diagnostic

If errors aren't being caught at all, add this temporary middleware right before your error handler to confirm errors are reaching the error pipeline:

// Temporary — remove after debugging
app.use((err, req, res, next) => {
  console.log('🚨 Error reached pipeline:', err.message);
  next(err);  // Pass it along to the real error handler
});

If you see the log, your errors are flowing correctly and the problem is in your error handler logic. If you don't see the log, errors aren't reaching the pipeline — check whether you're calling next(err) in your routes and whether async errors are being caught.

What to Learn Next

Frequently Asked Questions

Error middleware is a special Express middleware function with four parameters (err, req, res, next) that catches errors thrown anywhere in your app. Instead of your server crashing, error middleware intercepts the problem, sends a clean error response to the client, and keeps the server running.

Express identifies error-handling middleware by its function signature — it must have exactly four parameters (err, req, res, next). If you only use three parameters, Express treats it as regular middleware and your errors won't be caught. Even if you don't use the next parameter, you must include it.

Without error middleware, unhandled errors in Express cause your server to crash or send ugly HTML error pages to users. In production, this means downtime, leaked stack traces that expose your code structure, and a terrible user experience. Error middleware prevents all of these problems.

Async errors in Express require special handling because Express 4 doesn't automatically catch errors from async/await functions. You need either an async wrapper function (catchAsync) that catches rejected promises and passes them to next(err), or you need to use Express 5 which handles async errors natively.

Use both, but for different purposes. Try/catch is for handling expected errors locally in a specific route (like a missing database record). Error middleware is the centralized safety net that catches everything else — unexpected crashes, unhandled rejections, and any error you didn't anticipate. Think of try/catch as first aid and error middleware as the emergency room.