AlgoInsight

Free lesson · JavaScript Basic to Advance

Error Handling with async/await

Easy5 min read

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 await before try/catch. If you call asyncFn() without await inside a try, the function returns its Promise immediately. The try exits before the Promise rejects, and the catch doesn't catch the error. Always await inside try blocks.

Catching too broadly. A try/catch around 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 async function without await and not handling its Promise is the same as creating an unhandled rejection. Either await, 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/catch works with await, 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 regular try/catch that 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 async function fetchWithRetry(url, maxRetries) that:

  1. Calls a fake fetch(url) function (you can stub it to randomly succeed or fail)
  2. If fetch throws, retries up to maxRetries times
  3. If all retries fail, throws an error indicating all attempts failed
  4. If any retry succeeds, returns the result

Use try/catch for the retry logic. The structure should be a loop with the call inside try. Use a Promise-based delay helper 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.

Back to JavaScript Basic to Advance