Async errors
Async changes everything
The try-catch patterns from the last lesson work great for synchronous code. But our e-commerce API is full of async operations: calling the payment service, sending emails, checking inventory. Async code has different rules when it comes to errors, and those rules trip up a lot of developers.
Promises reject instead of throwing
Here is a mistake that looks reasonable but does not work:
// This does NOT catch the error
try {
fetch("https://api.payment.com/charge"); // Returns a promise, does not throw
} catch (err) {
console.log("Caught:", err); // Never runs
}
// The promise rejects later, after the try-catch has finished What do you think happens here? The fetch call returns a promise immediately. At that moment, there is no error. The try-catch block checks for synchronous errors (there are none), finishes, and moves on. Later, the promise rejects, but the try-catch is already gone. The rejection has nowhere to go.
A promise does not throw an error. It rejects. A rejected promise without a .catch() or an await becomes an unhandled rejection. That is a different thing entirely from a thrown error.
async/await fixes this
await bridges the gap between promises and try-catch. When you await a promise, execution pauses until the promise settles. If the promise rejects, await converts that rejection into a thrown error that try-catch can handle:
// This DOES catch the error
try {
await fetch("https://api.payment.com/charge"); // await converts rejection to throw
} catch (err) {
console.log("Caught:", err); // This runs
} The only difference is the await keyword. But it changes everything. Without await, the rejection escapes the try-catch. With await, the rejection is caught.
The rule is simple: always use await with promises inside try-catch. Without await, the rejection happens after the try-catch has finished, and your catch block never runs.
Multiple awaits
When you have several async operations in a row, each await can throw. A single try-catch handles all of them:
async function processOrder(userId: string, items: any[]) {
try {
const order = createOrder(userId, items);
await chargeCard(order.total, "tok_123"); // Can throw: payment fails
await sendEmail(userId, "Order confirmed", "Your order is on the way."); // Can throw: email service down
await reserveStock("prod-1", 2); // Can throw: inventory service error
return order;
} catch (err) {
// Any of the three awaits might have thrown
console.error("Order processing failed:", err.message);
throw err; // Re-throw for the route handler
}
} If chargeCard fails, execution jumps straight to the catch block. sendEmail and reserveStock never run. The error goes directly to the catch.
This is sequential execution. Each operation waits for the previous one to finish. If the first one fails, the rest are skipped. That is usually what you want for operations that depend on each other (you do not want to send a confirmation email if the payment failed).
Promise.all and errors
Sometimes you want to run multiple async operations at the same time instead of one after another. Promise.all does that:
try {
const [product, inventory, price] = await Promise.all([
fetchProduct("prod-1"),
checkInventory("prod-1"),
getLatestPrice("prod-1"),
]);
} catch (err) {
// One of the three promises rejected
// The other two might still be running (their results are discarded)
console.error("Failed:", err.message);
} Promise.all runs all three promises concurrently. If any one of them rejects, Promise.all rejects immediately with that error. The catch block runs.
[!WARNING] When
Promise.allrejects, the other promises keep running in the background. Their results are just discarded. If those promises have side effects (like charging a credit card), the side effects still happen. UsePromise.allSettledif you need to know the result of every promise regardless of failures.
Promise.allSettled
Unlike Promise.all (which fails fast on the first rejection), Promise.allSettled waits for every promise to complete and reports each result individually:
const results = await Promise.allSettled([
chargeCard(order.total, "tok_123"),
sendEmail(userId, "Order confirmed", "Your order is on the way."),
reserveStock("prod-1", 2),
]);
for (const result of results) {
if (result.status === "fulfilled") {
console.log("Success:", result.value);
} else {
console.error("Failed:", result.reason.message);
}
} Each result has a status property that is either "fulfilled" (success) or "rejected" (failure). For fulfilled results, the data is in result.value. For rejected results, the error is in result.reason.
Use Promise.allSettled when you want to attempt multiple operations and handle each result independently. For example, sending notifications to multiple users where some might fail but you still want to send the rest.
The unhandled rejection problem
What happens when a promise rejects and nothing handles it? You get a warning in the console:
// This creates an unhandled rejection
async function doSomething() {
throw new Error("Oops");
}
doSomething(); // No await, no .catch() The function returns a rejected promise. Nothing handles it. Node.js logs:
UnhandledPromiseRejectionWarning: Error: Oops This is a bug. It means an error happened and your code did not deal with it. The fix is straightforward: always await async functions or add .catch():
await doSomething(); // Option 1: await (errors handled by enclosing try-catch)
doSomething().catch(console.error); // Option 2: .catch() (fire-and-forget with logging) Option 1 is for operations you need to wait for. Option 2 is for fire-and-forget operations where you want to log errors but not block execution. We will use both patterns throughout the course.
Exercises
Exercise 1: Write a function that calls fetch without await inside a try-catch. Verify the error is NOT caught.
Exercise 2: Add await. Verify the error IS caught.
Exercise 3: Use Promise.allSettled to call three simulated services. Log which succeeded and which failed.
We now understand the fundamentals: error types, propagation, and how async errors work. In the next section, we will build structured error handling. Instead of throwing generic Error objects with string messages, we will create helper functions that build consistent error responses, so every route returns errors in the same format.
Why does try-catch not catch promise rejections without await?