Back to all posts
·5 min read

Async/Await Error Handling Done Right

Stop wrapping everything in try-catch. Learn practical, clean patterns for handling errors in async JavaScript code.

YB
Yogesh Bhardwaj
March 5, 2026

Async/Await Error Handling Done Right

If you've been writing async JavaScript for any amount of time, you've probably ended up with code that looks like this:

async function loadDashboard() {
  try {
    const user = await fetchUser();
    try {
      const posts = await fetchPosts(user.id);
      try {
        const comments = await fetchComments(posts[0].id);
        return { user, posts, comments };
      } catch (e) {
        console.error("Failed to load comments", e);
      }
    } catch (e) {
      console.error("Failed to load posts", e);
    }
  } catch (e) {
    console.error("Failed to load user", e);
  }
}

Nested try-catch blocks everywhere. It works, but it's hard to read, hard to maintain, and violates the very reason we moved to async/await — to write async code that looks synchronous and clean.

Let's look at better approaches.

The Tuple Pattern

Inspired by Go's error handling, you can wrap async calls to return [error, data] tuples:

async function to(promise) {
  try {
    const data = await promise;
    return [null, data];
  } catch (error) {
    return [error, null];
  }
}

Now your code reads linearly:

async function loadDashboard() {
  const [userErr, user] = await to(fetchUser());
  if (userErr) return handleError("user", userErr);

  const [postsErr, posts] = await to(fetchPosts(user.id));
  if (postsErr) return handleError("posts", postsErr);

  const [commentsErr, comments] = await to(fetchComments(posts[0].id));
  if (commentsErr) return handleError("comments", commentsErr);

  return { user, posts, comments };
}

Each error is handled inline, no nesting, and you can decide per-call whether an error is fatal or recoverable.

The Result Type

Taking the tuple pattern further, you can create a more structured Result type:

class Result {
  #value;
  #error;

  constructor(value, error) {
    this.#value = value;
    this.#error = error;
  }

  static ok(value) {
    return new Result(value, null);
  }

  static fail(error) {
    return new Result(null, error);
  }

  get isOk() {
    return this.#error === null;
  }

  unwrap() {
    if (this.#error) throw this.#error;
    return this.#value;
  }

  unwrapOr(fallback) {
    return this.isOk ? this.#value : fallback;
  }
}

This gives you a richer API for handling results:

const result = await fetchUser().then(Result.ok).catch(Result.fail);

const user = result.unwrapOr({ name: "Guest", role: "anonymous" });

Centralized Error Boundaries

For operations that share the same error handling logic, a single try-catch at the right level is perfectly fine:

async function loadDashboard() {
  const user = await fetchUser();
  const posts = await fetchPosts(user.id);
  const comments = await fetchComments(posts[0].id);
  return { user, posts, comments };
}

// Called from a higher level with a single error boundary
try {
  const dashboard = await loadDashboard();
  render(dashboard);
} catch (error) {
  showErrorPage(error);
}

This works well when:

  • All errors in a sequence should be handled the same way
  • You want to fail fast on any error
  • The caller is responsible for error presentation

Promise.allSettled for Parallel Operations

When running operations in parallel where some can fail independently:

async function loadPageData() {
  const results = await Promise.allSettled([
    fetchUser(),
    fetchNotifications(),
    fetchRecommendations(),
  ]);

  return {
    user: results[0].status === "fulfilled" ? results[0].value : null,
    notifications: results[1].status === "fulfilled" ? results[1].value : [],
    recommendations: results[2].status === "fulfilled" ? results[2].value : [],
  };
}

The page still loads even if notifications or recommendations fail. Only the user data might be truly critical.

Choosing the Right Pattern

ScenarioPattern
Different handling per callTuple pattern
Rich error/success modelingResult type
Same handling for all errorsSingle try-catch
Parallel independent operationsPromise.allSettled
Fire-and-forget operations.catch() on the promise

The Bottom Line

Error handling in async code doesn't have to be ugly. Choose a pattern that fits your use case, stay consistent across your codebase, and always think about what the user should experience when something goes wrong — not just what the developer console should show.

JavaScriptAsyncBest Practices