Centralized Error Handling in Node.js APIs

I explain how Express routes errors to a centralized handler, the difference between operational and programmer errors, and what changed in Express 5.

Centralized Error Handling in Node.js APIs

Every Express API I’ve built eventually ends up with the same pattern: some routes return JSON errors directly, some pass to next, a few have try-catch blocks that swallow the error silently, and occasionally one just throws and crashes the server. The error behavior isn’t wrong in any one place — it’s just different everywhere.

That inconsistency is a design problem. When ten routes each decide independently what an error looks like, you get five different error shapes in production, clients that can’t reliably parse failures, and stack traces that don’t reach your error handler.

The fix is one middleware function that owns the error response for every route. This post walks through how Express routes errors to that function, the distinction between operational and programmer errors, how to write structured error classes, and how async error forwarding changed in Express 5.

Series: Part 4 of 4 in the Express series. Start with Part 1: Building a REST API with Node.js and Express · Previous: Middleware in Express: From Basics to Real-World Use

On this page

The four-parameter signature

Express identifies error-handling middleware by the number of parameters in the function signature. A regular middleware takes three: (req, res, next). An error handler takes four: (err, req, res, next).

According to the Express documentation on error handling, Express routes errors to error-handling middleware when a handler calls next(err) — passing any truthy value other than 'route' to next — or when synchronous code inside a handler throws an exception.

The four-parameter variant must be declared with all four parameters, even if you don’t use next:

// This is an error handler — Express will route errors here
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ message: 'Something went wrong.' });
});

If you accidentally write only three parameters:

// This is NOT an error handler — Express treats it as regular middleware
app.use((req, res, next) => {
  res.status(500).json({ message: 'Something went wrong.' });
});

Express uses the function’s .length property to make this distinction. A three-parameter function will never receive errors, regardless of where you place it in the stack. When this happens, errors either produce a hanging request or fall through to Express’s built-in default handler, which in development mode dumps the full stack trace to the client.

Register the error-handling middleware after all routes and other app.use() calls. Express processes middleware in order; anything registered after the error handler won’t run for requests that produced an error before reaching it.

Operational errors vs programmer errors

Not all errors deserve the same treatment. There’s a distinction that comes from Node.js itself — and understanding it changes how you write error handlers.

Operational errors are expected failures: a user submits an invalid email, a resource isn’t found, an external service returns a 503. These are part of normal operation. Your API should catch them, return a meaningful HTTP response, and keep running.

Programmer errors are bugs: reading a property of undefined, an out-of-bounds array access, a logic branch that was never supposed to execute. These indicate something is broken in the code itself, not just the input. Continuing to run after a programmer error means your application is in an unknown state.

The practical consequence: operational errors should reach your error middleware and produce a structured response. Programmer errors should crash the process. You don’t want to paper over a bug by returning 500 Internal Server Error and pretending the server is still healthy.

Error type Example Correct response
Operational Invalid request body 400 with validation details
Operational Record not found 404
Operational Upstream service unavailable 503
Programmer Cannot read property of undefined Log the error, then exit
Programmer Unexpected null where object required Log the error, then exit

Distinguishing them in code requires marking errors when you throw them.

Structured error classes

A plain new Error('Not found') carries a message and a stack trace. It doesn’t carry an HTTP status code or a flag telling the error handler whether to send a response or crash the process.

Extending Error solves both:

class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

statusCode gives the error handler the HTTP status without guessing. isOperational = true marks the error as expected — it’s safe to send a response to the client. Errors without isOperational set are programmer errors, and the handler should treat them differently.

Throwing from a route:

app.get('/users/:id', async (req, res, next) => {
  const user = await findUser(req.params.id);
  if (!user) {
    throw new AppError('User not found', 404);
  }
  res.json(user);
});

And the centralized handler that reads it:

app.use((err, req, res, next) => {
  if (res.headersSent) {
    return next(err); // delegate to Express default if response already started
  }

  const statusCode = err.statusCode || 500;
  const message = err.isOperational ? err.message : 'Internal server error';

  res.status(statusCode).json({
    status: 'error',
    message,
  });

  if (!err.isOperational) {
    console.error('Programmer error:', err);
    // consider process.exit(1) or a graceful shutdown here
  }
});

One detail worth calling out: if headers have already been sent when the error arrives, calling res.status(...).json(...) will itself throw. The Express docs are explicit — delegate to the default error handler in that case, which closes the connection cleanly.

Check this before moving on

  • Your error-handling middleware has exactly four parameters, with err as the first
  • It’s registered after all routes and app.use() calls
  • Your error class sets isOperational = true on errors the client should receive
  • The handler checks res.headersSent before writing a response

Async error forwarding: Express 5 vs Express 4

This is where Express 5 genuinely changed the way I write route handlers.

In Express 4, async handlers didn’t automatically forward errors. If an async function threw, the error landed in the event loop as an unhandled rejection — it never reached Express. You had to forward it yourself:

// Express 4: manual forwarding required
app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await findUser(req.params.id);
    res.json(user);
  } catch (err) {
    next(err); // without this, the error disappears
  }
});

Some teams used .catch(next) on promise chains instead:

// Express 4: .catch(next) pattern
app.get('/users/:id', (req, res, next) => {
  findUser(req.params.id)
    .then(user => res.json(user))
    .catch(next);
});

Both worked, but the boilerplate added up fast across a codebase with many routes.

In Express 5, route handlers and middleware that return a Promise automatically call next(value) when they reject or throw. Because async functions always return a Promise, errors from async route handlers reach your error middleware without any wrapper:

// Express 5: errors forwarded automatically
app.get('/users/:id', async (req, res) => {
  const user = await findUser(req.params.id);
  if (!user) throw new AppError('User not found', 404);
  res.json(user);
});

The same four-parameter error handler catches both. Express 5 requires Node.js 18 or higher, so check your runtime version before upgrading.

Situation Express 4 Express 5
async function throws Unhandled rejection Forwarded to next automatically
Returned Promise rejects Must .catch(next) Forwarded automatically
Synchronous throw Forwarded automatically Forwarded automatically
Callback-based fs.readFile error Must call next(err) manually Must call next(err) manually

The callback-based case doesn’t change between versions. If you’re using a Node.js callback API, pass the error to next explicitly — Express can’t intercept it otherwise.

Global fallback handlers

Express error middleware handles errors that reach the middleware stack. Two categories of errors escape it entirely.

uncaughtException fires when a synchronous error is thrown and not caught anywhere — typically in a timer callback or event listener outside any route handler.

unhandledRejection fires when a Promise rejects with no .catch() handler. In Express 4 this was common for unhandled async route errors; in Express 5 it’s rarer for route code but still possible anywhere else in the application.

process.on('uncaughtException', (err) => {
  console.error('Uncaught exception:', err);
  process.exit(1); // exit — process state is unknown
});

process.on('unhandledRejection', (reason) => {
  console.error('Unhandled rejection:', reason);
  process.exit(1);
});

The process.exit(1) is deliberate. The Node.js documentation is clear that uncaught exceptions indicate an unknown application state. In production, a process manager or container orchestrator restarts the process after a crash — which is the correct behavior, not swallowing the error and continuing.

Register these handlers early, before any routes or middleware, so they cover the full application lifecycle.

Before you continue: What do you expect to happen if your error middleware calls next(err) again after already sending a response?

Express will call the next error handler in the stack. If none exists, it hands the error to its own default handler, which tries to set the status code on a response that already went out. The result is a “Cannot set headers after they are sent” error. This is why the res.headersSent check isn’t optional — it’s the guard that prevents a response from being sent twice.

Where centralized handling breaks down

Centralized error middleware works when errors reach the Express stack. A few places they don’t.

Errors during startup. If a database connection fails before the server starts listening, none of your Express handlers can catch it. Wrap startup logic in try-catch or use the global uncaughtException handler.

Errors in non-request code. Timer callbacks, event emitters, and background jobs that run outside a request lifecycle won’t route through Express. The global handlers above cover these, but they signal that something unexpected happened — not a normal failure.

next() called multiple times. If two code paths both call next(err) for the same request, the error handler runs twice. The second call usually produces a “headers already sent” warning. Keep a single exit path for errors in each handler, and don’t call next after a response has been sent.

Streaming responses. If your route is streaming a response and an error occurs mid-stream, the client has already started receiving data. You can’t override the status code at that point. Handle errors before sending any response bytes when streaming is involved.

Your routes don’t own the error shape

The shift this pattern requires is smaller than it looks. Route handlers produce errors — they don’t decide what the client receives. That decision belongs to one function at the end of the middleware stack.

Start by creating an AppError class and throwing instances from one route. Let the error handler decide the status code and response shape. Then extend it to the rest of your routes.

The production project structure post covers where to put this handler in a layered folder layout. If you’re new to how Express middleware execution works, the middleware guide explains the pipeline that makes centralized handling possible.

Once the error response shape is consistent, clients can parse failures reliably, logging becomes easier, and the routes themselves get shorter. That’s the practical gain from moving the responsibility out of every handler and into one.

Sources