Skip to content
QSWEQB
mediumConceptScenarioMidSenior

A request fails but your Express error-handling middleware never runs. How do you work out why?

Express recognises an error handler only by its four-parameter signature, and only if it is registered after the routes. Express 4 also ignores rejected promises from async handlers, so you must get those to next() yourself; Express 5 forwards them for you.

3 min readUpdated 2026-07-26

What the interviewer is scoring

  • Does the candidate know that Express selects an error handler by parameter count, not by naming or registration API
  • Whether they place the handler after the routes and can explain why position in the stack matters
  • That they distinguish a synchronous throw, which Express catches, from a rejected promise, which Express 4 does not
  • Whether they name the Express 5 behaviour change rather than treating async wrappers as eternally necessary
  • Does the candidate connect an unrouted rejection to the process exiting, not merely to a missing response

Answer

Express finds the handler by counting parameters

There is no app.useErrorHandler. Express walks its middleware stack and, when it is carrying an error, skips every layer whose handler function does not declare exactly four parameters. The check is on function arity, so (err, req, res, next) is registered as an error handler and (err, req, res) is registered as ordinary middleware that will simply never be reached during normal request flow, because Express calls three-argument middleware with req, res, next and your err parameter would receive the request.

This is the failure that survives code review, because both versions look plausible. It also means any transformation that changes arity breaks the handler silently: a default value ((err, req, res, next = noop)), a rest parameter, or a wrapper that forwards with (...args) and therefore reports a length of zero. In TypeScript, annotating the function as express.ErrorRequestHandler gets you type checking on the shape but not a guarantee about arity if you then wrap it.

// Registered as an error handler: arity is exactly 4.
app.use((err, req, res, next) => {
  if (res.headersSent) return next(err); // response already streaming - let the default handler close it
  res.status(err.status ?? 500).json({ error: err.message });
});

Position in the stack decides what it can see

next(err) only moves forwards. An error handler mounted before the routes it is meant to protect will never be consulted for their errors, so error middleware belongs after every app.use and route registration. The same rule applies one level down: a handler mounted inside a sub-router covers errors raised by that router's own layers, and anything it re-raises with next(err) propagates outwards to the parent application's handlers.

If nothing in the chain claims the error, Express's built-in final handler responds, which in a development environment is the stack-trace HTML page candidates usually recognise. Seeing that page is itself the diagnostic: the error was routed, but not to your handler.

Synchronous throws are caught; Express 4 rejections are not

An error thrown synchronously inside a handler is caught by Express in every version, because the handler is invoked inside a try block. The distinction that matters is what happens once the function is async. An async function does not throw; it returns a promise that rejects, and the throw happens after the call has already returned.

Express 4 discards a handler's return value, so nothing ever attaches a rejection listener to that promise. The request hangs until the client times out, and separately Node reports an unhandled rejection. Since Node 15 the default for unhandled rejections is to throw, so an unrouted rejection terminates the process rather than printing a warning: one bad row in a database result can take down the instance. The fixes are to end the promise chain with .catch(next), to wrap handlers in a higher-order function that does it for you, or to import express-async-errors, which monkey-patches the router so it awaits handler results.

Express 5 changes this. Middleware and handlers that return a rejecting promise have the rejected value forwarded to the error-handling middleware as though you had called next(err), which is why the wrapper pattern is no longer needed on 5. The same applies to a four-argument error handler that is itself async; a rejection inside it propagates to the next error handler rather than vanishing.

// Express 4: without .catch(next) this rejects into the void.
app.get('/users/:id', async (req, res, next) => {
  try {
    res.json(await findUser(req.params.id));
  } catch (err) {
    next(err);
  }
});

Async work that escapes the promise the handler returns

The belief that an async wrapper, or an upgrade to Express 5, makes every asynchronous error routable is what separates an adequate answer from a strong one. Both mechanisms work by observing the single promise the handler returns. An error thrown inside a setTimeout callback, inside an 'error' event listener, or inside a callback-style API you did not promisify is not part of that promise, so it reaches neither your handler nor Express at all — it surfaces as an uncaughtException. Wiring those paths up means promisifying them, or attaching the emitter's error event to next explicitly.

So the diagnosis has an order. Confirm the handler's arity, confirm it sits after the routes, then confirm the error is actually inside the promise Express is watching.

An error handler that is never called and an error that never reaches Express look identical from the client's side, but only the second one can kill the process.

Likely follow-ups

  • What happens if you call next(err) after the response has already been sent?
  • How would you shape a single error handler so it returns JSON to API clients and a page to browsers?
  • Where does an error thrown inside a stream's data callback end up?
  • How does Nest's exception filter model differ from Express error middleware?

Related questions

Further reading

expresserror-handlingmiddlewareasync-awaitnodejs