Express.js / Basic: request and response ownership / Lesson 6
Synchronous and asynchronous error contracts
Forward each failure once and translate it into a safe response only while the response remains writable.
On this page
Lesson goals
- Explain Express 5 handling of rejected returned promises and the limits of callback capture.
- Map known domain outcomes without exposing internal exceptions or trusting arbitrary status fields.
Classify failure before formatting it
A validation rejection, inaccessible object, stale revision and unexpected source outage need different application outcomes. Some are expected domain results and others are failures requiring controlled internal evidence. Express error middleware provides a transport path, not a universal taxonomy for business rules. Use stable public codes and appropriate status choices without exposing a database message or stack trace. A request that reaches no route is an ordinary unmatched case unless the application explicitly converts it; it does not automatically invoke error middleware merely because the final result should be 404.
Return the promise that owns asynchronous work
In Express 5, a route or middleware that returns a promise has its rejection forwarded to error handling automatically. An async handler naturally returns such a promise, so awaiting the domain operation keeps the failure connected to the route lifecycle. A detached promise or a timer callback is not automatically captured simply because it was started inside that handler. Avoid both manually forwarding a rejected promise and rethrowing it into the automatic path, which can duplicate error handling. The fragment relies on Express 5 specifically and is not a claim about older major versions.
app.get("/courses/:id", async (req,res) => {
const course = await catalog.findVisible(req.principal,req.params.id);
if (!course) return res.status(404).json({code:"COURSE_NOT_FOUND"});
return res.json({id:course.id,title:course.title});
});
app.use((err,req,res,next) => {
if (res.headersSent) return next(err);
const plan = publicFailure(err);
return res.status(plan.status).json(plan.body);
});
The returned async promise connects lookup failure to error middleware. The four-argument handler and publicFailure dependency remain illustrative and unexecuted.
Callbacks and response commitment need explicit ownership
Errors thrown in later timer or event callbacks occur outside the original synchronous handler stack. Convert the operation to an awaited promise where appropriate or catch and forward a callback error through its documented path. Keep forwarding single-owned and do not continue with a response after next(error). If headers have already been sent, a clean replacement response is no longer available; delegate to the remaining error path and release resources according to the stream or connection policy. A public failure formatter cannot roll back a committed database change or retract bytes already delivered.
Map only known safe categories
The offline mapper recognizes a narrow validation code and hides every other exception behind a generic 500 representation. It intentionally ignores an arbitrary status property on an unrecognized error. A real application may use trusted typed errors and a richer policy, but should not expose a source exception merely because it contains plausible fields. The local assertions check that private text and a misleading status do not escape. This function neither logs the error nor supplies authentication, incident correlation or transport handling; those remain separate responsibilities with controlled disclosure.
function publicFailure(error) {
if (error?.code === "INVALID_TITLE") return {status:400,body:{code:"INVALID_TITLE"}};
return {status:500,body:{code:"INTERNAL_ERROR"}};
}
console.assert(publicFailure({code:"INVALID_TITLE"}).status === 400);
const plan = publicFailure({status:200,message:"private-source-detail"});
console.assert(plan.status === 500);
console.assert(JSON.stringify(plan.body) === '{"code":"INTERNAL_ERROR"}');
The known category maps to 400; an unknown exception with a misleading status becomes a generic 500 without its message.
Test rejection before effects and failure after effects
For each write route, test denied access and invalid input with a source spy proving no write occurred. Also test a source failure before commit and a lost response after commit, because the latter leaves an uncertain outcome rather than definite nonexecution. Verify one forwarding path, safe public output and appropriate cleanup. Production configuration can influence Express’s default error disclosure, so use an intentional application policy and independently review the exact environment. This course executes the local mapper only; it does not run an Express engine, callback integration, live source failure or process crash test.
Common errors and fixes
A detached rejection bypasses the route error path
- Why it happens
- The handler did not return or await the promise that owned the work.
- How to fix it
- Connect the promise lifecycle or give the background task a separate explicit owner.
- Prevent it next time
- Test failed asynchronous branches, not only resolved promises.
Error middleware never receives the error
- Why it happens
- It was registered before the failing route or lacked the four-argument signature.
- How to fix it
- Place a correctly shaped error handler after the relevant stack.
- Prevent it next time
- Verify one known failure through the actual composed stack.
A source exception leaks private details
- Why it happens
- The raw exception or untrusted message was used as the public body.
- How to fix it
- Map known categories and retain richer evidence only in controlled internal telemetry.
- Prevent it next time
- Assert exact public keys and absence of private messages.
Check your understanding
1. Why can an async route still have an unhandled timer callback failure?
Show worked solution
The timer callback runs later and is not necessarily part of the promise returned by the async route. Express can handle the returned promise’s rejection, but cannot infer ownership of unrelated callbacks. Wrap supported callback work into an awaited operation or explicitly catch and forward under one owner; do not assume the async keyword captures all future work.
2. Why is a timeout after commit different from validation rejection before a write?
Show worked solution
The validation rejection can be tested to produce no write, while the committed operation may already have changed durable state despite the missing response. A blind retry can repeat the effect. Reconcile through the route’s operation identity and idempotency contract, and keep the error response policy separate from transaction and replay safety.
Key takeaways
- Express 5 captures rejected returned promises, not every unrelated future callback.
- A safe error response does not undo an effect or resolve write uncertainty.