Asynchronous code fails just as often as synchronous code — maybe more so. A timer fires late, a value arrives malformed, or a condition you assumed would never happen does. Without a plan for these failures, a rejected Promise becomes an unhandled rejection, which in modern JavaScript terminates your program or logs a loud warning.
You already know two ways to manage failure: .catch() on a Promise chain, and try/catch around ordinary code. Async/await gives you a third way — try/catch around await — which feels so natural that you will reach for it almost every time. This lesson teaches when to use each pattern and the one trap that makes every beginner's try/catch silently useless.
flowchart TD P["Promise"] -->|"resolves"| S[".then(v => ...)"] P -->|"rejects"| R[".catch(e => ...)"] style S fill:#1e7f3d,color:#fff style R fill:#b91c1c,color:#fff
.catch() on the chain
Every .then() can be followed by a .catch(). If the Promise rejects, the engine skips every .then() and runs the first .catch() it finds. The .catch() handler receives the rejection reason — usually an Error object — and can either recover by returning a value or re-throw to propagate the failure down the chain.
This pattern is perfect for quick, one-line transformations at the end of a chain. It is less ideal when you have multiple await steps and want a single error handler for all of them. That is where try/catch shines.
function delay(ms, ok) {
return new Promise((resolve, reject) => {
setTimeout(() => ok ? resolve("data") : reject("network error"), ms);
});
}
delay(10, false)
.then(v => console.log("success:", v))
.catch(e => console.log("caught:", e));
try/catch around await
Inside an async function, await on a rejected Promise throws the rejection as an exception. That means you can wrap it in the same try/catch you use for synchronous code. The mental model is identical: try the risky line, catch what goes wrong, and keep going.
This is the biggest ergonomic win of async/await. Instead of sprinkling .catch() after every step, you write one try block around the whole sequence and one catch block that handles every failure in that sequence. The code reads top-to-bottom, and the error handling lives right next to the code it protects.
flowchart TD T["try block"] --> A["await risky()"] A -->|"rejects"| C["catch(e)"] A -->|"resolves"| N["next line"] style C fill:#b91c1c,color:#fff style N fill:#1e7f3d,color:#fff
function delay(ms, ok) {
return new Promise((resolve, reject) => {
setTimeout(() => ok ? resolve("data") : reject("network error"), ms);
});
}
async function fetchData(ok) {
try {
const v = await delay(10, ok);
console.log("got:", v);
return v;
} catch (e) {
console.log("failed:", e);
return "fallback";
}
}
fetchData(false);
finally: cleanup no matter what
Sometimes you need to run code whether the operation succeeded or failed — closing a file, hiding a loading spinner, or resetting a flag. The finally block runs after try and catch regardless of which path was taken.
One subtle point: if finally contains a return statement, it overrides any return from try or catch. This is rare in practice, but worth knowing when you read library code. In your own code, use finally for side effects like cleanup, not for returning values.
flowchart TD T["try"] -->|"success"| F["finally"] T -->|"throws"| C["catch"] C --> F style F fill:#e0900b,color:#fff
function delay(ms, ok) {
return new Promise((resolve, reject) => {
setTimeout(() => ok ? resolve("data") : reject("fail"), ms);
});
}
async function task(ok) {
try {
const v = await delay(10, ok);
console.log("result:", v);
} catch (e) {
console.log("error:", e);
} finally {
console.log("cleanup");
}
}
task(false);
What does this print? .catch() on a rejected Promise returns a new Promise object.
const p = Promise.reject("fail");
const q = p.catch(e => "recovered");
console.log(q instanceof Promise);
console.log(typeof q.then);
.catch() always returns a new Promise.
Every Promise has a .then method, so typeof q.then is 'function'.
Write an async function fetchStatus(shouldFail) that returns "ok" when shouldFail is false. When shouldFail is true, throw new Error("fail") inside a try block, catch it, and return "recovered".
async function fetchStatus(shouldFail) {
// return 'ok' or catch the throw and return 'recovered'
}
Use if (shouldFail) inside the try block to decide whether to throw.
The catch block returns the recovery string.
async function fetchStatus(shouldFail) {
try {
if (shouldFail) {
throw new Error("fail");
}
return "ok";
} catch (e) {
return "recovered";
}
}
This function is meant to catch a rejected Promise and return "fallback". But the author forgot await, so the Promise object is assigned instead of awaited, the rejection is never thrown inside the try block, and the catch never runs. Add the missing keyword.
async function load() {
try {
const v = Promise.reject("fail");
return v;
} catch (e) {
return "fallback";
}
}
Without await, the variable holds a Promise object, not the resolved value.
Add await before Promise.reject so the rejection is thrown and caught.
async function load() {
try {
const v = await Promise.reject("fail");
return v;
} catch (e) {
return "fallback";
}
}
In an async function, what happens if await somePromise rejects and there is no try/catch around it?
If an awaited Promise rejects and there is no try/catch, the async function itself returns a rejected Promise. The caller must handle that rejection with .catch() or another try/catch.
Write an async function attempt(task) that awaits task() and returns its result. If task() rejects, catch the error and return "fallback". Use a try/catch block.
async function attempt(task) {
// await task(), return result or 'fallback' on rejection
}
Await task() inside the try block and return its result.
The catch block returns the fallback string.
async function attempt(task) {
try {
return await task();
} catch (e) {
return "fallback";
}
}
The rejection nobody catches
A rejected promise with no handler does not vanish quietly. The runtime notices that nothing ever dealt with it and reports an unhandled rejection — in the browser that is an unhandledrejection event and a console error; in Node it terminates the process by default.
This is easy to cause by accident. Firing a promise and walking away — save(data); instead of await save(data); — leaves nobody holding the failure. The same happens when a .catch() sits on the wrong link: attaching it before a .then() that itself throws means the new rejection flows past the handler that already ran.
The rule is short: every promise chain needs exactly one place where failure stops. Either await it inside a try/catch, or end the chain with .catch(). A promise you neither await nor catch is a bug waiting for a bad day.
Recap
.catch()on a Promise chain handles rejection and can recover by returning a value.- Inside an async function,
awaiton a rejected Promise throws; wrap it intry/catchexactly like synchronous code. - Forgetting
awaitinsidetry/catchis a silent failure — the Promise object is assigned, the rejection escapes, and the catch block never runs. finallyruns aftertryorcatch, making it ideal for cleanup that must happen regardless of outcome.- If no handler catches a rejection, the async function returns a rejected Promise for its caller to deal with.
Next you will learn how to run multiple async tasks in parallel and safely collect all their results with Promise.all.