try/catch and the detached-promise gapasync function processOrder(orderId) {
try {
const order = await fetchOrder(orderId);
await chargeCard(order);
} catch (err) {
console.error("Order processing failed:", err);
}
}
try/catch around await works exactly the way it does for synchronous throws — a rejection surfaces at the await line as if it were thrown there. But it only catches what it actually wraps: fire off a Promise without awaiting it inside that same try block, and its eventual rejection happens after the function may have already moved on (or returned), completely outside the try/catch's reach:
async function processOrder(orderId) {
try {
const order = await fetchOrder(orderId);
chargeCard(order); // missing await! this Promise is now detached
} catch (err) {
console.error("Order processing failed:", err); // never sees a chargeCard() rejection
}
}
The try/catch here only ever sees a fetchOrder failure. A chargeCard rejection happens on its own detached Promise, unconnected to this function's control flow at all — it'll either trigger unhandledRejection or vanish, but it will never land in that catch block. The fix is simply not forgetting the await.
Rather than duplicating error-response formatting in every route, route handlers forward errors with next(err) and let one error-handling middleware (see the Express section above) format the actual response:
app.get("/orders/:orderId", async (req, res, next) => {
try {
const order = await fetchOrder(req.params.orderId);
res.json(order);
} catch (err) {
next(err); // hand off — don't format the error response here too
}
});
app.use((err, req, res, next) => {
const status = err.status || 500;
res.status(status).json({ error: status < 500 ? err.message : "Internal server error" });
});
That last line is deliberate: for a genuine server-side failure (5xx), leak the actual internal error message to the client and you risk exposing implementation details (stack traces, library names, sometimes even query fragments) to whoever's making the request. Client-error responses (4xx) are safe to describe precisely, since they're describing something about their request.
| Operational errors | Programmer errors | |
|---|---|---|
| Examples | Invalid user input, a downstream API timing out, a database connection dropping | A TypeError from calling a method on undefined, an off-by-one bug, a missed await |
| What they represent | Expected failure modes of a working system | Actual bugs in the code |
| Right response | Catch it, respond to the client appropriately (a 400, a 503, a retry), keep the process running | Log it with full context, and generally let the process exit — don't try to keep serving traffic on an app whose internal state might now be corrupted |
The distinction should genuinely change how you write the handling code, not just how you label the error. Operational errors are things your application is designed to expect and recover from — a malformed request body isn't a bug, it's an input the client is allowed to send, so catching it and returning a clean 400 is the entire job. A programmer error, by contrast, means some invariant your code assumed was already violated somewhere upstream — swallowing it and pretending to continue risks running the rest of the request (or subsequent requests, if shared state got corrupted) against a program that's already in a state its author never intended. That's why process managers (covered in the deployment section) restarting a crashed process is a reasonable strategy for programmer errors, but would be a terrible way to handle routine invalid input.