Skip to content
Express.js ex errors 4 min read

Error-Handling Middleware

Express recognizes error-handling middleware by a single distinguishing feature: its function takes four arguments — (err, req, res, next) — instead of the usual three. When any handler calls next(err) or (in Express 5) rejects an async promise, Express skips the rest of the normal stack and jumps straight to your four-argument handlers. Centralizing that logic in one place gives you consistent JSON responses, a single point for logging, and the ability to hide implementation details from clients in production.

The four-argument signature

Express counts the arity of every function you register. A function with exactly four parameters is treated as an error handler; anything else is normal middleware. This is why the err parameter must be present even if you reference it via _err — removing it silently turns your handler back into ordinary middleware that never receives errors.

const express = require('express');
const app = express();

app.get('/users/:id', async (req, res, next) => {
  const user = await db.findUser(req.params.id);
  if (!user) {
    const err = new Error('User not found');
    err.status = 404;
    return next(err); // forward to the error handler
  }
  res.json(user);
});

// Error-handling middleware — note the four arguments
app.use((err, req, res, next) => {
  res.status(err.status || 500).json({ error: err.message });
});

Tip: The four-argument signature is positional, not name-based. Even if you do not use next, you must still declare it so Express sees four parameters.

Register it last

Error handlers must be registered after all routes and other middleware. Express walks the stack in registration order, so a handler defined too early would never be reached by errors thrown in later routes. Define your routes, mount your routers, and only then call app.use with the four-argument handler.

app.use('/api/users', usersRouter);
app.use('/api/orders', ordersRouter);

// 404 handler — runs when no route matched (normal middleware, 3 args)
app.use((req, res, next) => {
  const err = new Error('Not Found');
  err.status = 404;
  next(err);
});

// Centralized error handler — always last
app.use((err, req, res, next) => {
  // handled below
});

Sending JSON errors with a status

A robust handler derives the HTTP status from the error, logs the failure, and returns a predictable JSON shape. Default to 500 when no status is attached so unexpected bugs still surface cleanly.

app.use((err, req, res, next) => {
  // If headers were already sent, delegate to Express's default handler
  if (res.headersSent) return next(err);

  const status = err.status || err.statusCode || 500;

  // Log full details server-side
  console.error(`[${req.method} ${req.originalUrl}]`, err);

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

Output:

HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8

{"error":{"message":"User not found","status":404}}

Logging and hiding stack traces in production

In development a stack trace is invaluable; in production it leaks internal paths, dependency versions, and logic that helps attackers. Gate the verbose details behind NODE_ENV. Log the full error on the server regardless, but return only a safe message to the client — and never expose a stack trace for unhandled 500s.

app.use((err, req, res, next) => {
  if (res.headersSent) return next(err);

  const status = err.status || 500;
  const isProd = process.env.NODE_ENV === 'production';

  // Always log server-side (replace console with pino/winston in real apps)
  console.error(err);

  const body = {
    error: {
      // For 5xx in production, hide the real message behind a generic one
      message: status >= 500 && isProd ? 'Internal Server Error' : err.message,
      status,
    },
  };

  // Only include the stack outside production
  if (!isProd) body.error.stack = err.stack;

  res.status(status).json(body);
});

Warning: Client-facing 4xx messages (validation, not-found) are usually safe to surface, but 5xx messages often contain internal detail. Always replace them with a generic message in production.

Behavior reference

ConcernRule
SignatureMust be (err, req, res, next) — exactly four parameters
Registration orderAfter every route, router, and the 404 handler
Triggeringnext(err), a sync throw, or a rejected async promise (Express 5)
Status codeRead err.status / err.statusCode, default to 500
Already-sent responseCheck res.headersSent and return next(err) to delegate
Multiple handlersAllowed — each must call next(err) to pass control along

Express 4 vs 5

In Express 4, only synchronous throws and explicit next(err) calls reach the error handler; a rejected promise in an async route hangs unless you forward it. Express 5 awaits handler results and auto-forwards rejections to your four-argument middleware. The handler itself is identical in both versions — only how errors reach it differs.

Best Practices

  • Register the four-argument handler last, after all routes, routers, and a 404 handler.
  • Keep the err and next parameters even when unused so Express recognizes the four-argument arity.
  • Always check res.headersSent and delegate with next(err) to avoid “headers already sent” crashes.
  • Derive the status from err.status / err.statusCode, defaulting to 500 for unexpected errors.
  • Log the full error server-side with a structured logger, but return only safe messages to clients.
  • Hide stack traces and internal 5xx messages when NODE_ENV === 'production'.
  • Use a single centralized handler instead of repeating res.status().json() in every route.
Last updated June 14, 2026
Was this helpful?