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
unhandledRejectionwarning 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.
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:
- 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. - Express 4 doesn't know about promises. An
asynchandler returns a promise; when it rejects, nothing callsnext(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
- Node's HTTP server emits a request; Express creates
reqandres. - Application-level middleware runs in registration order (
app.use), then routers and route handlers whose path and method match. Each callsnext()to continue or sends a response to finish. - Calling
next(err)(or throwing in synchronous code) skips to the next error-handling middleware. - If nothing responds, Express's final handler returns 404; an unhandled error produces its default 500 page.
- 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
nextis unused, and register them after routes. - Always
returnafter 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
unhandledRejectionevents and on requests that end without a response (load balancer timeouts with no app log line). - Lint rules such as
@typescript-eslint/no-misused-promisesflag 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.