A request fails but your Express error-handling middleware never runs. How do you work out why?
An Express error handler is skipped when it is not registered after routes, lacks the four-argument signature, or an async error never reaches next(). Express 4 needs explicit next(err) for rejected promises; Express 5 forwards them automatically.
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
Short answer
If Express error middleware is not catching errors, check three things: it must have four parameters (err, req, res, next), it must be registered after the routes, and async failures must be passed to next(err) in Express 4. Express 5 forwards rejected async handlers, but ordering and signature still matter.
Mention error handling where it changes the risk, the owner, or the next check.
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.
© 2026 Preptima. Originally published at preptima.com.
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
- Your logs are full of Cannot set headers after they are sent. Walk me through how a request gets into that state.mediumAlso on express and error-handling4 min
- Walk me through the middleware chain of an Express service. What has to come before what, and why?mediumAlso on express and middleware4 min
- A promise rejects and nothing is awaiting it. What does Node do?hardAlso on error-handling and nodejs4 min
- Your Node service has low CPU but latency spikes for every request at the same moment. What is happening?mediumAlso on nodejs4 min