Skip to content
Node.js nd error-handling 5 min read

Centralized Error Handling in Express

Scattering try/catch and res.status(500).send(...) across every route handler is a maintenance trap — the formatting drifts, some paths leak stack traces, and others forget to respond at all. Express solves this with a dedicated kind of middleware that takes four arguments and runs only when an error is forwarded to it. By funneling every failure through one place, you get a single, consistent error response shape and one spot to wire up logging. This page shows how the four-argument signature works, how to forward errors with next(err), how to capture errors from async handlers, and how to build a centralized response format.

The four-argument error middleware

Express distinguishes ordinary middleware from error-handling middleware purely by arity. A function declared with four parameters — (err, req, res, next) — is treated as an error handler and is skipped during normal request flow. It only executes when something upstream calls next() with an argument.

import express from 'express';

const app = express();

app.get('/users/:id', (req, res) => {
  res.json({ id: req.params.id, name: 'Ada' });
});

// Error-handling middleware — note the four parameters
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal Server Error' });
});

app.listen(3000);

Because Express detects error handlers by counting parameters, you must keep all four — even if you do not use next. Dropping it to (err, req, res) turns the function back into a regular middleware and it will never receive errors.

Register error middleware last, after all routes and other app.use() calls. Express runs middleware in order, so an error handler placed before your routes can never catch errors they throw.

Forwarding errors with next(err)

Inside a synchronous handler, a plain throw is caught automatically and routed to your error middleware. But the canonical, always-works mechanism is to pass the error to next. Calling next(err) with any truthy argument tells Express to skip every remaining normal handler and jump straight to the error chain.

app.get('/orders/:id', (req, res, next) => {
  const order = findOrder(req.params.id);
  if (!order) {
    const err = new Error('Order not found');
    err.status = 404;
    return next(err); // hand off to the error handler
  }
  res.json(order);
});

The error handler can then read your custom properties — like err.status — to shape the response instead of always returning 500.

Handling async route errors

This is the classic Express gotcha. A rejected Promise inside an async handler is not automatically forwarded. If you await something that throws and do nothing, the request simply hangs until the client times out.

// BROKEN — the rejection is never passed to Express
app.get('/report', async (req, res) => {
  const data = await buildReport(); // if this throws, request hangs
  res.json(data);
});

Express 4 ignores rejected Promises returned from handlers. Express 5 (stable since 2024) changed this: a returned Promise that rejects is automatically passed to next, so the wrapper below is unnecessary there. The pattern still matters for the large installed base of Express 4 apps.

On Express 4, the fix is to catch and forward explicitly, or wrap handlers in a helper so you never forget.

// A reusable wrapper that forwards any async rejection to next()
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get('/report', asyncHandler(async (req, res) => {
  const data = await buildReport(); // a throw here now reaches the error handler
  res.json(data);
}));

Promise.resolve(...).catch(next) works because next is a function that accepts the rejection value as its argument — exactly the next(err) call you would write by hand.

A centralized error response format

With every error flowing to one handler, you can enforce a uniform JSON envelope and decide what to expose. Read a status (or statusCode) off the error, default to 500, and hide internal details in production.

import express from 'express';

const app = express();
app.use(express.json());

app.get('/widgets/:id', (req, res, next) => {
  const err = new Error('Widget does not exist');
  err.status = 404;
  err.code = 'WIDGET_NOT_FOUND';
  next(err);
});

// Centralized handler — single source of truth for error responses
app.use((err, req, res, next) => {
  const status = err.status ?? 500;
  const isServerError = status >= 500;

  if (isServerError) {
    console.error(`[${req.method} ${req.path}]`, err.stack);
  }

  res.status(status).json({
    error: {
      message: isServerError ? 'Internal Server Error' : err.message,
      code: err.code ?? 'INTERNAL_ERROR',
      status,
    },
  });
});

app.listen(3000, () => console.log('Listening on http://localhost:3000'));

Requesting GET /widgets/42 returns a clean, predictable body:

Output:

HTTP/1.1 404 Not Found
Content-Type: application/json

{
  "error": {
    "message": "Widget does not exist",
    "code": "WIDGET_NOT_FOUND",
    "status": 404
  }
}

Note the isServerError guard: client errors (4xx) echo a helpful message, while 5xx responses return a generic string so you never leak stack traces or internal messages to callers. Pair this with custom error classes so each error already carries its own status and code, keeping the handler tiny.

If headers were already sent when an error fires, you cannot start a new response. Express’s default handler checks res.headersSent and delegates to the built-in handler in that case — do the same in custom handlers: if (res.headersSent) return next(err);.

ConcernWhere to handle it
Mapping error to HTTP statusSet err.status at throw site; read it in the handler
Hiding internals on 5xxBranch on status >= 500 in the handler
Async rejections (Express 4)asyncHandler wrapper or .catch(next)
Async rejections (Express 5)Automatic — returned rejected Promises go to next
LoggingOnce, inside the centralized handler

Best Practices

  • Register the error-handling middleware last, after all routes, so it can catch everything upstream.
  • Always declare the full (err, req, res, next) signature — Express identifies error handlers by their four parameters.
  • Forward errors with next(err) (or .catch(next)); never call res directly from a handler and next(err) for the same request.
  • On Express 4, wrap async handlers with an asyncHandler helper so rejected Promises reach the error middleware instead of hanging.
  • Attach a status and a stable code to errors so the central handler can map them to the right HTTP response.
  • Return generic messages for 5xx errors and never expose err.stack to clients in production.
  • Guard against double responses with if (res.headersSent) return next(err); at the top of the handler.
Last updated June 14, 2026
Was this helpful?