Module 5 · Asynchronous JavaScript ⏱ 19 min

Error Handling in Async Code

By the end of this lesson you will be able to:
  • Catch Promise rejections with .catch()
  • Wrap await in try/catch to handle errors like synchronous code
  • Use finally to run cleanup regardless of success or failure

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
A Promise can resolve to a value or reject with an error. The consumer chooses how to handle each path.

.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.

A rejected Promise is caught by .catch() and recovered.
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
await on a rejected Promise throws inside the async function; try/catch catches it like any other exception.
try/catch around await feels exactly like synchronous error handling.
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
finally runs after try or catch, no matter which path was taken.
finally runs whether the await succeeds or fails.
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);
Exercise

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);
Exercise

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'
}
Exercise

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";
  }
}
Exercise

In an async function, what happens if await somePromise rejects and there is no try/catch around it?

Exercise

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
}

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, await on a rejected Promise throws; wrap it in try/catch exactly like synchronous code.
  • Forgetting await inside try/catch is a silent failure — the Promise object is assigned, the rejection escapes, and the catch block never runs.
  • finally runs after try or catch, 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.

Checkpoint quiz

What is the classic mistake that makes a try/catch around await silently useless?

When does the finally block run?

Go deeper — technical resources