Handling Async Errors With try/catch in JavaScript

Master async error handling in JavaScript with try/catch and async/await. Learn how await transforms rejections into throws, handle errors in parallel operations, build reliable async error boundaries, and avoid common pitfalls.

JavaScriptintermediate
12 min read

Synchronous try/catch works beautifully with async/await because await converts Promise rejections into thrown exceptions at the exact await line. This means the same try/catch syntax you use for synchronous code works for async code with minimal adjustment. However, there are important differences in how errors propagate in async contexts that can catch developers off-guard.

How await Transforms Rejections Into Throws

The fundamental mechanism:

javascriptjavascript
// This:
async function example() {
  try {
    const data = await fetchData(); // If fetchData() rejects...
    process(data);
  } catch (err) {
    // ...the rejection reason lands here as 'err'
    console.error(err.message);
  }
}
 
// Is equivalent to:
function example() {
  return fetchData()
    .then((data) => process(data))
    .catch((err) => {
      console.error(err.message);
    });
}

When an awaited Promise rejects, JavaScript throws at the await expression, transferring control to the nearest enclosing catch block — exactly as if it had been a throw statement.

Basic Pattern: Single Operation

javascriptjavascript
async function loadUser(id) {
  try {
    const user = await fetchUser(id);
    return user;
  } catch (error) {
    console.error("Failed to load user:", error.message);
    return null;
  }
}

Multiple Operations in One try Block

One try/catch can cover multiple await expressions. The first rejection stops execution and jumps to catch:

javascriptjavascript
async function loadProfile(userId) {
  try {
    const user = await fetchUser(userId);       // If this rejects...
    const profile = await fetchProfile(user.id); // ...this never runs
    const settings = await fetchSettings(userId); // ...nor does this
    return { user, profile, settings };
  } catch (error) {
    // We get here from whichever await rejected
    // But we don't know WHICH one without extra context
    console.error("Profile load failed:", error.message);
    return null;
  }
}

Important: Without additional context on the error object (or separate try/catch blocks), you cannot determine which await caused the rejection from inside the catch block. If different operations need different recovery strategies, use nested try/catch:

javascriptjavascript
async function loadProfileRobust(userId) {
  // Critical: must succeed
  let user;
  try {
    user = await fetchUser(userId);
  } catch (error) {
    throw new Error("User not found", { cause: error });
  }
 
  // Optional: fail gracefully with defaults
  let profile = {};
  try {
    profile = await fetchProfile(user.id);
  } catch {
    console.warn("Profile unavailable, using defaults");
  }
 
  return { user, profile };
}

Async Errors That try/catch CANNOT Catch

A common source of confusion: try/catch cannot catch errors in callbacks that are not awaited:

javascriptjavascript
// WRONG: try/catch does NOT catch this
async function example() {
  try {
    setTimeout(() => {
      throw new Error("Inside setTimeout"); // Uncaught! Not caught by try/catch
    }, 100);
  } catch (err) {
    console.log("Caught:", err); // Never runs
  }
}
 
// WRONG: try/catch does NOT catch Promise rejections without await
async function example2() {
  try {
    fetchData(); // No await — rejection is unhandled
    doOtherStuff();
  } catch (err) {
    console.log("Caught:", err); // Never runs for fetchData rejection
  }
}

Rule: try/catch only catches what is synchronously thrown OR what is await-ed and rejects. For anything else, use .catch() directly on the Promise or a global error handler.

Handling Errors in Parallel Operations

With Promise.all, a single rejection rejects the whole group:

javascriptjavascript
// Promise.all: try/catch catches the first rejection
async function loadAll(ids) {
  try {
    const users = await Promise.all(
      ids.map((id) => fetchUser(id))
    );
    return users;
  } catch (err) {
    // Caught the FIRST rejection from any fetchUser call
    console.error("Batch load failed:", err.message);
    return [];
  }
}
 
// Promise.allSettled: no rejection reaches try/catch — all outcomes are in the array
async function loadAllSafe(ids) {
  const results = await Promise.allSettled(  // Never rejects
    ids.map((id) => fetchUser(id))
  );
 
  const failed = results.filter((r) => r.status === "rejected");
  if (failed.length > 0) {
    console.warn(`${failed.length} users failed to load`);
  }
 
  return results
    .filter((r) => r.status === "fulfilled")
    .map((r) => r.value);
}

Error Bubbling in async Functions

An unhandled rejection in an async function causes the returned Promise to reject, which bubbles up to the caller:

javascriptjavascript
async function step1() {
  throw new Error("step1 failed");
}
 
async function step2() {
  await step1(); // step1 rejects → throws here
  // Never reaches here
}
 
async function main() {
  try {
    await step2(); // step2 rejects → throws here
  } catch (err) {
    console.error("Caught at top level:", err.message); // "step1 failed"
  }
}

The error message, type, and stack trace are preserved through the entire chain. The original new Error("step1 failed") is what you catch at the top level.

async Error Boundaries (Framework Pattern)

In UI frameworks, an "error boundary" is a component that catches errors from its children. The async equivalent is a wrapper function that catches all errors from an async operation and handles them centrally:

javascriptjavascript
// Generic async error boundary
async function safeRun(asyncFn, fallback = null, errorHandler = console.error) {
  try {
    return await asyncFn();
  } catch (err) {
    errorHandler(err);
    return typeof fallback === "function" ? fallback(err) : fallback;
  }
}
 
// Usage
const user = await safeRun(
  () => fetchUser(id),
  null,
  (err) => Sentry.captureException(err)
);
 
// If fetchUser rejects: user = null, error is sent to Sentry
// If fetchUser resolves: user = the actual user object

Return await vs Return for Error Capture

A subtle but important difference in try/catch with async returns:

javascriptjavascript
// Version A: return (no await)
async function getDataA() {
  try {
    return fetchData(); // Returns the Promise directly
    // If fetchData rejects AFTER this function returns, try/catch does NOT catch it
  } catch (err) {
    console.log("Caught:", err); // May NOT catch fetchData rejection
  }
}
 
// Version B: return await
async function getDataB() {
  try {
    return await fetchData(); // Awaits the Promise inside the try block
    // If fetchData rejects, it throws HERE → caught by CATCH
  } catch (err) {
    console.log("Caught:", err); // WILL catch fetchData rejection
  }
}

When you have a try/catch in an async function and want to catch rejections from the return expression, use return await.

Pattern: Async-safe Error Logging

javascriptjavascript
async function withErrorLogging(label, asyncFn) {
  const start = performance.now();
  try {
    const result = await asyncFn();
    const duration = performance.now() - start;
    console.log(`[${label}] completed in ${duration.toFixed(1)}ms`);
    return result;
  } catch (err) {
    const duration = performance.now() - start;
    console.error(`[${label}] failed after ${duration.toFixed(1)}ms:`, err.message);
    throw err; // Always rethrow — this is logging, not recovery
  }
}
 
// Usage
const user = await withErrorLogging("fetchUser", () => fetchUser(id));
Rune AI

Rune AI

Key Insights

  • await converts rejections to throws: An awaited rejected Promise throws at the await line — exactly as if it were throw error, enabling standard try/catch to work
  • Non-awaited rejections bypass try/catch: Only what is awaited (or synchronously thrown) is catchable; unhandled Promises, callbacks, and setTimeout errors are not
  • return await matters inside try/catch: return promise escapes the try block before rejection is observable; return await promise awaits it inside, enabling local catch
  • Use allSettled for partial-failure tolerance: Promise.all + try/catch catches the first failure; Promise.allSettled handles all outcomes without needing try/catch at the allSettled level
  • Error bubbling is predictable: Unhandled async function rejections propagate to the caller's await/catch — the error type and stack trace are preserved through the entire async call chain
RunePowered by Rune AI

Frequently Asked Questions

Why doesn't try/catch work with setTimeout callbacks?

`setTimeout` schedules a callback in a future macrotask. By the time the callback runs, the try/catch block has already exited (the try block completed synchronously). The callback executes in a completely different call stack with no enclosing try/catch. Use a Promise-wrapped setTimeout or attach an error handler inside the callback itself.

Can I use try/catch in a class with async methods?

Yes, without any special syntax. Class methods can be `async` and use try/catch normally: ```javascript class UserService { async getUser(id) { try { return await this.db.find(id); } catch (err) { throw new NotFoundError("User", id); } } } ```

Is there a way to avoid deeply nested try/catch blocks?

Yes — use the tuple-return pattern: `async function attempt(p) { try { return [null, await p] } catch(e) { return [e, null] } }`. This lets you handle errors inline with `const [err, data] = await attempt(fetchUser(id))` without nesting.

What happens if the catch block itself throws?

The `catch` block's throw propagates as a rejection from the async function. If there is a `finally` block, it still runs before the rejection propagates. The original error is NOT preserved unless you manually use `new Error("...", { cause: originalErr })`.

Conclusion

Async error handling with try/catch works naturally because await translates rejections into throws. The key rules are: always await Promises you want try/catch to cover, use return await (not return) inside try blocks when you want the rejection caught locally, and use Promise.allSettled instead of Promise.all when partial failure is acceptable. For comprehensive error handling, combine try/catch with custom error classes and advanced try/catch patterns.