The first version of async error handling most people write is one big try block around everything, and one catch that logs error.message and calls it done. That works for a demo. It falls apart once a function calls three other async functions and you need to know which one actually failed, and what to do differently for each.
try per failure domain, not per functionWrapping an entire function body in one try means every possible failure (a bad network call, a parse error, a validation error) lands in the same catch, indistinguishable from each other.
// Bad: can't tell which step failed, or respond differently to each
async function processOrder(orderId) {
try {
const order = await fetchOrder(orderId);
const payment = await chargeCard(order.cardToken, order.total);
await sendReceipt(order.email, payment);
} catch (error) {
console.log("something went wrong", error);
}
}// Better: each failure domain handled where it makes sense
async function processOrder(orderId) {
const order = await fetchOrder(orderId).catch((err) => {
throw new OrderNotFoundError(orderId, { cause: err });
});
const payment = await chargeCard(order.cardToken, order.total).catch(
(err) => {
throw new PaymentFailedError(order.id, { cause: err });
},
);
// Receipt failure shouldn't roll back a successful charge:
// log and continue instead of throwing
await sendReceipt(order.email, payment).catch((err) =>
logger.warn("receipt email failed", { orderId, cause: err }),
);
return payment;
}The receipt failure is deliberately not re-thrown: the payment already succeeded, and failing the whole order because an email didn't send would be wrong. That distinction (which failures are fatal to the overall operation and which aren't) is exactly what one undifferentiated try/catch can't express.
class PaymentFailedError extends Error {
constructor(orderId, options) {
super(`Payment failed for order ${orderId}`);
this.name = "PaymentFailedError";
this.orderId = orderId;
this.cause = options?.cause;
}
}The cause option (standard since ES2022) preserves the original error instead of swallowing it: you get both "what went wrong at this layer" and "what actually threw underneath," without string-concatenating a stack trace by hand.
Downstream, this lets a caller branch on error type instead of parsing a message string:
try {
await processOrder(orderId);
} catch (err) {
if (err instanceof PaymentFailedError) {
return res
.status(402)
.json({ error: "payment_failed", orderId: err.orderId });
}
if (err instanceof OrderNotFoundError) {
return res.status(404).json({ error: "order_not_found" });
}
throw err; // unrecognized error, don't silently swallow it
}That last line matters as much as the specific if branches: re-throwing anything you don't recognize is what stops a generic catch from quietly hiding bugs it was never written to handle.
Promise.allSettled when partial failure is acceptablePromise.all rejects as soon as any promise rejects, discarding the results of everything else: fine when every step is required, wrong when you're firing off independent operations and want to know the outcome of all of them.
const results = await Promise.allSettled([
notifyUser(order.userId),
updateInventory(order.items),
logAnalyticsEvent(order),
]);
const failures = results.filter((r) => r.status === "rejected");
if (failures.length) {
logger.warn("some post-order steps failed", { failures });
}Each of these three side effects can fail independently without blocking the other two, and without throwing away the fact that they succeeded.
Flip the requirement and Promise.all is the right call, not the wrong one. Building a checkout page needs the order, the user's saved cards, and current inventory: all three, or there's nothing to render.
try {
const [order, cards, stock] = await Promise.all([
fetchOrder(orderId),
fetchSavedCards(userId),
checkInventory(orderId),
]);
return renderCheckout(order, cards, stock);
} catch (err) {
throw new CheckoutUnavailableError(orderId, { cause: err });
}One missing piece and there's no page to show, so the first rejection should short-circuit the other two instead of waiting on results nobody can use.
| Situation | Pattern |
|---|---|
| Multiple async steps, need to know which failed | One try/catch per step, not one around everything |
| Need to branch on failure type downstream | Custom error classes, checked with instanceof |
| A failure shouldn't roll back an already-succeeded step | .catch() that logs, doesn't re-throw |
| Independent async calls, want all outcomes | Promise.allSettled / asyncio.gather(return_exceptions=True) |
| Caught an error type you don't recognize | Re-throw it, don't silently swallow unknown errors |
| Preserving the original error when wrapping | cause option (JS) / raise ... from err (Python) |
The underlying discipline is catch at the point where you know what to do about a specific failure, not at the outermost point where you've lost the information to do anything but log it.