Error & Async Handling Interview Questions
Error handling separates toy Express apps from production-grade ones, and interviewers know it. The hard questions are rarely about syntax — they probe whether you understand how Express routes errors through the four-argument middleware, why rejected promises slip through the cracks in Express 4, and what changed in Express 5. The Q&A below walks through each of these with precise, runnable answers.
How does Express identify error-handling middleware?
Express distinguishes error middleware purely by arity: a middleware function declared with four parameters — (err, req, res, next) — is treated as an error handler. Regular middleware has three. This is the single most quoted fact on the topic, and the fourth parameter is mandatory even if you never use it, because Express inspects fn.length to decide how to invoke it.
import express from "express";
const app = express();
app.get("/boom", (req, res, next) => {
next(new Error("Something failed"));
});
// four args => Express recognizes this as an error handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({ error: err.message });
});
app.listen(3000);
Output:
{ "error": "Something failed" }
Error middleware must be registered after all routes and normal middleware, so that errors propagated upstream actually reach it.
How do you forward an error to the error handler?
Synchronous throws inside a route handler are caught by Express automatically and routed to error middleware. For anything asynchronous, you forward the error explicitly by passing it to next(err). Any truthy argument to next is treated as an error and causes Express to skip every remaining regular middleware, jumping straight to the next four-argument handler.
app.get("/orders/:id", (req, res, next) => {
if (!/^\d+$/.test(req.params.id)) {
return next(new Error("Invalid id")); // skip to error handler
}
res.json({ id: req.params.id });
});
Tip:
next()advances to the next normal handler;next(err)jumps to error handling;next("route")skips the rest of the current route’s handlers and tries the next matching route.
Why are async errors not caught in Express 4?
This is the classic trap. In Express 4.x, a route handler that returns a rejected promise — or throws after an await — is not caught. Express only wraps the synchronous invocation of the handler; once control returns to the event loop, the rejection becomes an unhandled promise rejection and your error middleware never runs. The request hangs until the client times out.
// Express 4.x: this REJECTION is NOT caught — request hangs
app.get("/user/:id", async (req, res) => {
const user = await db.find(req.params.id); // if this throws...
res.json(user); // ...we never get here
});
The fix in Express 4 is to try/catch and forward manually:
app.get("/user/:id", async (req, res, next) => {
try {
const user = await db.find(req.params.id);
res.json(user);
} catch (err) {
next(err); // forwarded to error middleware
}
});
How can you avoid repeating try/catch everywhere?
A common follow-up: write a wrapper that catches rejected promises and forwards them, so handlers stay clean. This is exactly what libraries like express-async-handler do under the hood.
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get("/user/:id", asyncHandler(async (req, res) => {
const user = await db.find(req.params.id); // a rejection now goes to next(err)
res.json(user);
}));
What changed in Express 5?
Express 5 makes async handling first-class: if a route or middleware function returns a promise that rejects, Express automatically forwards the error to your error-handling middleware. The manual try/catch and asyncHandler wrapper become unnecessary for the common case.
| Behavior | Express 4.x | Express 5.x |
|---|---|---|
Sync throw in handler | Caught automatically | Caught automatically |
| Rejected promise / async throw | NOT caught — request hangs | Caught and routed to error middleware |
| Need a wrapper helper | Yes, in practice | No |
path-to-regexp wildcards | * style | Named params (/*splat) |
// Express 5.x: no try/catch needed
app.get("/user/:id", async (req, res) => {
const user = await db.find(req.params.id); // rejection auto-forwarded
res.json(user);
});
How should you handle uncaught exceptions and unhandled rejections?
Even with perfect routing, bugs escape. Express middleware can only catch errors that flow through the request pipeline — it cannot catch errors in timers, event emitters, or background jobs. For those, attach process-level listeners, log, and exit so a supervisor (PM2, systemd, Kubernetes) can restart cleanly. Continuing after an uncaught exception leaves the process in an undefined state.
process.on("unhandledRejection", (reason) => {
console.error("Unhandled rejection:", reason);
throw reason; // promote to uncaughtException
});
process.on("uncaughtException", (err) => {
console.error("Uncaught exception:", err);
process.exit(1); // let the process manager restart us
});
Warning: Do not use
uncaughtExceptionas a global try/catch to keep running. The recommended pattern is log, flush, and exit — the process is no longer trustworthy.
Best Practices
- Define error middleware with all four arguments and register it after every route.
- Forward errors with
next(err)instead of sending responses from deep inside helpers. - In Express 4, wrap async handlers with a
Promise.resolve(...).catch(next)helper; in Express 5 rely on built-in forwarding. - Centralize error responses in one handler so status codes and JSON shapes stay consistent.
- Distinguish operational errors (bad input, 4xx) from programmer errors (bugs, 5xx) and respond accordingly.
- Add
unhandledRejectionanduncaughtExceptionlisteners that log and exit, then let a supervisor restart. - Never leak stack traces to clients in production — return a generic message and log the detail server-side.