Request Lifecycle: How Web Frameworks Handle a Request · JavaScript Frameworks: Express, NestJS and Next.js

Express: The Middleware Stack and Error Handling

Why requests to one endpoint hung until the client timed out, and how Express decides which middleware and error handlers run.

Scenario

Example app: an Express 4 API converts a route to async/await. When the database is unavailable, the route throws. The team has an error handler that logs and returns JSON.

What you see in production

  • During a database blip, requests to that route hang for 30 seconds until the load balancer gives up.
  • The JSON error handler never logs anything; the process logs an unhandledRejection warning instead.
  • A second error handler someone wrote with three parameters is also never called.

The naive code

app.get("/orders", async (req, res) => {
  const orders = await db.query("SELECT ...");   // rejects when the DB is down
  res.json(orders);
});

app.use((err, req, res) => {                      // 3 parameters: Express treats it as normal middleware
  res.status(500).json({ error: "internal" });
});

Reproduce it

The simulation is a tiny Express-style dispatcher: middleware run in registration order, next(err) skips to functions with four parameters, and Express 4 ignores the promise returned by an async handler, while Express 5 forwards its rejection to the error handlers.

Async errors and error-handler arity in Express 4 vs 5Try it yourself
function makeApp(version) {
  const stack = [];
  return {
    use(fn) { stack.push(fn); },
    async handle(req) {
      let result = "no response (request hangs until a timeout)";
      const res = { json: (status, body) => { result = `${status} ${JSON.stringify(body)}`; } };
      let index = 0;
      let pendingError = null;
      const next = (err) => {
        if (err) pendingError = err;
        while (index < stack.length) {
          const fn = stack[index++];
          const isErrorHandler = fn.length === 4;          // Express checks the declared arity
          if (pendingError && isErrorHandler) { const e = pendingError; pendingError = null; return fn(e, req, res, next); }
          if (!pendingError && !isErrorHandler) {
            const out = fn(req, res, next);
            if (out && typeof out.then === "function") {
              if (version === 5) return out.catch((e) => next(e));      // Express 5 forwards rejections
              return out.catch(() => console.log("  [process] unhandledRejection - Express 4 ignored the promise"));
            }
            return out;
          }
        }
        if (pendingError) result = "500 default HTML error page";
      };
      await next();
      return result;
    },
  };
}

const route = async (req, res) => { throw new Error("database unavailable"); };
const threeArgs = (err, req, res) => res.json(500, { error: "three-arg handler" });
const fourArgs = (err, req, res, next) => res.json(500, { error: err.message });

for (const version of [4, 5]) {
  for (const [label, handler] of [["3-arg handler", threeArgs], ["4-arg handler", fourArgs]]) {
    const app = makeApp(version);
    app.use(route);
    app.use(handler);
    console.log(`Express ${version}, ${label}: ${await app.handle({ path: "/orders" })}`);
  }
}

Output

  [process] unhandledRejection - Express 4 ignored the promise
Express 4, 3-arg handler: no response (request hangs until a timeout)
  [process] unhandledRejection - Express 4 ignored the promise
Express 4, 4-arg handler: no response (request hangs until a timeout)
Express 5, 3-arg handler: 500 default HTML error page
Express 5, 4-arg handler: 500 {"error":"database unavailable"}

Root cause

Two separate Express rules:

  1. Error-handling middleware is recognised by arity: only functions declared with four parameters (err, req, res, next) receive errors. A three-parameter function is treated as normal middleware and skipped while an error is pending.
  2. Express 4 doesn't know about promises. An async handler returns a promise; when it rejects, nothing calls next(err), so no response is ever sent. Express 5 forwards rejected promises from middleware and handlers to the error handlers.

The lifecycle, step by step

  1. Node's HTTP server emits a request; Express creates req and res.
  2. Application-level middleware runs in registration order (app.use), then routers and route handlers whose path and method match. Each calls next() to continue or sends a response to finish.
  3. Calling next(err) (or throwing in synchronous code) skips to the next error-handling middleware.
  4. If nothing responds, Express's final handler returns 404; an unhandled error produces its default 500 page.
  5. Sending a response twice causes ERR_HTTP_HEADERS_SENT ("Cannot set headers after they are sent"): return after responding.

See the guides on using middleware and error handling.

The fix

  • Upgrade to Express 5, or in Express 4 wrap async handlers so rejections call next(err) ((fn) => (req, res, next) => fn(req, res, next).catch(next)).
  • Declare error handlers with all four parameters, even if next is unused, and register them after routes.
  • Always return after sending a response in middleware.
  • Set server timeouts and a final catch-all error handler that logs with the request ID.

Trade-offs

  • Wrapping every handler by hand is error-prone; prefer Express 5 or a framework with promise-aware handlers (Fastify, NestJS).
  • Global error handlers should hide internal details from clients but log them fully.

Detect it in production

  • Alert on unhandledRejection events and on requests that end without a response (load balancer timeouts with no app log line).
  • Lint rules such as @typescript-eslint/no-misused-promises flag promises passed where callbacks are expected.

Key points

  • Express runs middleware in registration order; next(err) jumps to four-argument error handlers.
  • Express 4 ignores rejected promises from async handlers; Express 5 forwards them.
  • Register error handlers last, with four parameters, and never send a response twice.

Practice questions

How does Express recognise error-handling middleware?

Answer

By its arity: a middleware function declared with four parameters (err, req, res, next) is an error handler. When next(err) is called, Express skips normal middleware and calls the next error handler.

What happens when an async route handler throws in Express 4, and what changed in Express 5?

Answer

In Express 4 the returned promise rejects but Express ignores it, so no error handler runs and the request hangs (plus an unhandledRejection). Express 5 catches rejected promises from middleware and handlers and forwards them to error-handling middleware.

What causes 'Cannot set headers after they are sent' in Express?

Answer

Sending or modifying the response after it has already been sent, typically by calling res.send/res.json twice or calling next() after responding without returning.

Open in the app to mark lessons complete, take notes and track your progress.