Free lesson · JavaScript Basic to Advance
Error Handling with async/await
Errors That Read Like Normal Errors
The cleanest part of async/await is error handling. Where Promises use .catch, async/await uses the regular try/catch syntax you already know. This is one of the biggest readability wins of the modern syntax.
async function loadData(shouldFail) {
if (shouldFail) {
throw new Error("Failed to load");
}
return { name: "Olena" };
}
async function main() {
try {
const data = await loadData(false);
console.log("Got:", data);
const data2 = await loadData(true); // throws
console.log("Got 2:", data2); // skipped
} catch (err) {
console.log("Caught:", err.message);
}
}
main();
try/catch works exactly the same way it does for synchronous errors. Inside an async function, any rejection from an await expression becomes a thrown error that try/catch can handle. This is the bridge between the Promise rejection mechanism and the synchronous error mechanism.
This Topic covers the patterns for handling errors with async/await, the common mistakes, and how to mix synchronous and asynchronous error handling correctly.
How try/catch Catches Promise Rejections
When you await a Promise that rejects, the await expression throws. The thrown value is whatever was passed to reject:
async function fetchOrFail(shouldFail) {
if (shouldFail) {
return Promise.reject(new Error("Network error"));
}
return Promise.resolve("data");
}
async function example() {
try {
const a = await fetchOrFail(false);
console.log("a:", a);
const b = await fetchOrFail(true); // throws
console.log("b:", b); // skipped
} catch (err) {
console.log("Caught:", err.message);
}
console.log("After try/catch");
}
example();
The Promise rejection becomes a regular thrown error inside the try block. The catch block handles it. Execution continues after the try/catch. This is the same mechanism as catching a synchronous error.
The conceptual shift: under async/await, async errors look and behave like sync errors. You don't need a different mental model for handling them. This is why the syntax has become so popular.
Interactive animation: A rejected await turns into a throw, and one try/catch guards both awaits with ordinary syntax.
Throwing From async Functions
When you throw inside an async function, the function's returned Promise rejects with whatever was thrown:
async function validate(value) {
if (value == null) {
throw new Error("Value is required");
}
if (typeof value !== "string") {
throw new TypeError("Value must be a string");
}
return value.trim();
}
async function main() {
try {
await validate(null);
} catch (err) {
console.log("1:", err.message);
}
try {
await validate(42);
} catch (err) {
console.log("2:", err.name, err.message);
}
console.log("3:", await validate(" hello "));
}
main();
Inside an async function, throw works exactly like sync throw. The function exits, the Promise rejects with the thrown value, and any caller that awaits the function sees the error.
This makes input validation and precondition checks clean. You can throw early, knowing the error will propagate cleanly through await chains.
The Pattern: try/catch Around Each Concern
In real code, you don't usually wrap every await in its own try/catch. You group operations that share an error handling strategy:
async function loadUserDashboard(userId) {
try {
const user = await fetchUser(userId);
const orders = await fetchUserOrders(user.id);
const recommendations = await fetchRecommendations(user.id);
return { user, orders, recommendations };
} catch (err) {
console.log("Failed to load dashboard:", err.message);
return null;
}
}
function fetchUser(id) {
return Promise.resolve({ id, name: "Olena" });
}
function fetchUserOrders(id) {
return Promise.resolve([{ id: 1 }, { id: 2 }]);
}
function fetchRecommendations(id) {
return Promise.reject(new Error("Service unavailable"));
}
loadUserDashboard(1).then(r => console.log("Result:", r));
Three operations, one try/catch. If any of them fails, the whole dashboard load fails. You decide once how to handle errors, not three times.
The decision of how granularly to wrap try/catch depends on what error recovery is possible. If all three operations are critical and any failure should abort, one wrapper is correct. If some operations are optional and you want to recover, you'd wrap them individually:
async function loadDashboardResilient(userId) {
let user, orders, recommendations;
try {
user = await fetchUser(userId);
} catch (err) {
console.log("User load failed:", err.message);
return null; // can't continue without user
}
try {
orders = await fetchUserOrders(user.id);
} catch (err) {
console.log("Orders failed, continuing with empty:", err.message);
orders = []; // optional — continue
}
try {
recommendations = await fetchRecommendations(user.id);
} catch (err) {
console.log("Recommendations failed, continuing without:", err.message);
recommendations = []; // optional — continue
}
return { user, orders, recommendations };
}
function fetchUser(id) { return Promise.resolve({ id, name: "Olena" }); }
function fetchUserOrders(id) { return Promise.resolve([{ id: 1 }]); }
function fetchRecommendations(id) { return Promise.reject(new Error("Down")); }
loadDashboardResilient(1).then(r => console.log("Result:", r));
Each operation has its own try/catch with a recovery strategy. The dashboard partially loads even if some pieces fail. This pattern is common in real applications, especially in dashboards and feeds where partial data is better than no data.
Letting Errors Propagate
You don't always have to catch every error. async functions naturally propagate errors to their callers — if you don't try/catch inside an async function, any rejection from await causes the function's returned Promise to reject.
async function inner() {
throw new Error("Inner failure");
}
async function outer() {
await inner(); // throws, propagates
console.log("never reached");
}
async function main() {
try {
await outer(); // catches the error from inner
} catch (err) {
console.log("Caught at main:", err.message);
}
}
main();
The error from inner propagates through outer (which doesn't handle it) and is caught at main. This is the same as exception propagation in synchronous code.
The implication: you can choose where to catch errors based on where you can do something useful with them. Low-level functions don't have to wrap everything in try/catch if higher-level functions are better positioned to handle failures.
Interactive animation: Throw low, catch at the boundary - a rejection rides up past helpers and feature logic to the handler that responds.
A Subtle Trap: Errors in Independent Awaits
When you have multiple independent awaits in a function, an error in one stops the function before the others complete:
async function risky1() {
throw new Error("Risky 1 failed");
}
async function risky2() {
console.log("Risky 2 ran");
return "ok";
}
async function sequentialBoth() {
try {
await risky1();
await risky2();
} catch (err) {
console.log("Caught:", err.message);
}
}
sequentialBoth();
risky2 never runs because risky1 threw. The await doesn't continue past the error. If you actually wanted both to attempt to run regardless of the other, you'd need to handle them in parallel:
async function risky1() { throw new Error("R1 failed"); }
async function risky2() { console.log("R2 ran"); return "ok"; }
async function parallelBoth() {
const results = await Promise.allSettled([risky1(), risky2()]);
console.log("Results:", results);
}
parallelBoth();
Promise.allSettled runs all operations regardless of whether some fail. We'll cover it in detail in Topic 9.8. The point here: if you need "all of these should run, and I want to know about each independently," sequential awaits aren't the right pattern.
Common Pitfalls
Warning: The async error-handling traps.
Forgetting to
awaitbeforetry/catch. If you callasyncFn()withoutawaitinside atry, the function returns its Promise immediately. Thetryexits before the Promise rejects, and thecatchdoesn't catch the error. Alwaysawaitinsidetryblocks.Catching too broadly. A
try/catcharound an entire function body catches every error. This is sometimes what you want, but it also can hide bugs by treating all errors as recoverable. Be deliberate about scope.Ignoring errors from non-awaited Promises. Calling an
asyncfunction withoutawaitand not handling its Promise is the same as creating an unhandled rejection. Eitherawait, attach.catch, or be explicit that you're "fire and forget."
Interactive animation: Inside a try, a returned promise rejects after the try has exited - return await keeps the catch alive.
Why This Matters in Interviews
Interview: Error handling in async code is a senior-level topic. The expected answers include: how
try/catchworks withawait, when to catch broadly vs narrowly, and the difference between catching errors and letting them propagate.A specific question that comes up: "Compare error handling in callbacks, Promises, and async/await." The senior answer covers the evolution — callbacks repeated error checks at every level, Promises propagated errors through
.catch, async/await uses regulartry/catchthat reads exactly like synchronous error handling.Topic 9.12 — Interview Lens: Promise and async/await Patterns — covers the deeper treatment.
Try It
Exercise: Write an
asyncfunctionfetchWithRetry(url, maxRetries)that:
- Calls a fake
fetch(url)function (you can stub it to randomly succeed or fail)- If
fetchthrows, retries up tomaxRetriestimes- If all retries fail, throws an error indicating all attempts failed
- If any retry succeeds, returns the result
Use
try/catchfor the retry logic. The structure should be a loop with the call insidetry. Use a Promise-baseddelayhelper if you want to wait between retries.By the time this works, you've combined error handling with control flow in async code — a real production pattern.