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.
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
- Walk me through the middleware chain of an Express service. What has to come before what, and why?mediumAlso on express and middleware4 min
- Your Node service has low CPU but latency spikes for every request at the same moment. What is happening?mediumAlso on nodejs4 min
- You added an authorisation policy to an ASP.NET Core endpoint and it is not being enforced. Where do you look?mediumAlso on middleware3 min
- Why does calling .Result or .Wait() on an async method deadlock, and how do you fix it properly?hardAlso on async-await5 min
- How do you test a service that calls a third-party API, without calling it?mediumAlso on nodejs4 min
- Your function returns nil on the success path, the caller checks err != nil, and the check fires anyway. What happened?hardAlso on error-handling4 min
- Why would you stream a large file rather than read it into memory, and what is backpressure?mediumAlso on nodejs4 min
- A dashboard has been showing the same temperature for two days and nobody noticed. Walk the path and tell me what would have caught it.hardSame kind of round: scenario6 min