JavaScript Interview Prep
Asynchronous JavaScript

Error Handling in Async Code

The Silent Killer

LinkedIn Hook

Every async bug that ever hit your production logs at 3am started the same way — someone forgot a try/catch, or fired a Promise without a .catch(), and the error disappeared into the void. Until it didn't.

In Node.js 15+, an unhandled rejection crashes the process by default. In browsers, it bypasses your UI error boundary and shows up as a raw console warning. In both, the user sees a frozen screen, a spinner that never stops, or — worst of all — a silent failure where a "save" button did nothing but claimed it did.

This lesson walks through every error-handling pattern you actually need: try/catch around await, .catch() at the end and in the middle of a chain, a Go-style [err, result] utility to kill try/catch boilerplate, global handlers for the last line of defense, and the "floating Promise" anti-pattern — the single biggest source of silent async bugs in real codebases.

Get this right and async errors become predictable. Get it wrong and every bug is a ghost.

Read the full lesson -> [link]

#JavaScript #InterviewPrep #AsyncJS #ErrorHandling #Frontend #CodingInterview #WebDevelopment


Error Handling in Async Code thumbnail


What You'll Learn

  • Every pattern for handling async errors — try/catch, chain .catch(), mid-chain recovery, and Go-style tuples
  • What a "floating Promise" is and why it's the #1 cause of silent bugs in async code
  • How to set up global unhandled-rejection handlers as a last-resort safety net in browser and Node

What Happens If You Forget try/catch with await

This is the silent killer in async code. Imagine a safety net with a hole in it:

async function riskyFunction() {
  const data = await fetch("https://api.fake-url.com/data");
  // If fetch rejects, this function returns a rejected Promise
  // If nobody catches it...
  return data.json();
}

// PROBLEM: No .catch() on the call
riskyFunction();
// Uncaught (in promise) TypeError: Failed to fetch
// In Node.js, this can crash your process!

The .catch() Chaining Pattern

// Pattern 1: .catch() at the end of a chain
fetchUser()
  .then((user) => fetchProfile(user.id))
  .then((profile) => fetchPosts(profile.userId))
  .then((posts) => renderPosts(posts))
  .catch((error) => {
    // Catches error from ANY step above
    console.error("Failed:", error.message);
    showErrorPage();
  });

// Pattern 2: .catch() mid-chain for recovery
fetchUser()
  .then((user) => fetchProfile(user.id))
  .catch((error) => {
    // Handle profile fetch failure — return a default
    console.warn("Profile fetch failed, using default");
    return { name: "Anonymous", avatar: "default.png" };
  })
  .then((profile) => {
    // This runs with either the real profile or the default
    renderProfile(profile);
  });

A mid-chain .catch() is how you recover from a specific failure and keep the rest of the chain running — a trick you can't cleanly do with a single top-level try/catch.


try/catch with async/await

// Approach 1: Wrapping the whole block
async function loadPage() {
  try {
    const user = await fetchUser();
    const posts = await fetchPosts(user.id);
    const comments = await fetchComments(posts[0].id);
    renderPage({ user, posts, comments });
  } catch (error) {
    console.error("Page load failed:", error);
    renderErrorPage();
  } finally {
    hideLoadingSpinner();
  }
}

// Approach 2: Per-operation error handling (when you need different handling)
async function loadPageGranular() {
  let user;
  try {
    user = await fetchUser();
  } catch (error) {
    console.error("User fetch failed");
    return renderLoginPage(); // can't continue without user
  }

  let posts;
  try {
    posts = await fetchPosts(user.id);
  } catch (error) {
    console.warn("Posts unavailable");
    posts = []; // graceful degradation
  }

  renderPage({ user, posts });
}

A Helper Pattern to Avoid try/catch Boilerplate

// Utility function — wraps any promise into a [error, result] tuple
async function to(promise) {
  try {
    const result = await promise;
    return [null, result];
  } catch (error) {
    return [error, null];
  }
}

// Usage — clean, Go-style error handling
async function loadUser() {
  const [err, user] = await to(fetchUser(1));
  if (err) {
    console.error("Failed:", err.message);
    return;
  }

  const [err2, posts] = await to(fetchPosts(user.id));
  if (err2) {
    console.warn("Posts failed, continuing without them");
  }

  renderDashboard({ user, posts: posts || [] });
}

Global Unhandled Rejection Handlers

// Browser
window.addEventListener("unhandledrejection", (event) => {
  console.error("Unhandled promise rejection:", event.reason);
  event.preventDefault(); // prevents default console error
  // Send to error tracking service (Sentry, etc.)
});

// Node.js
process.on("unhandledRejection", (reason, promise) => {
  console.error("Unhandled Rejection at:", promise, "reason:", reason);
  // Log it, but don't crash — or choose to crash gracefully
});

These are safety nets, not primary handlers. Every Promise should still have its own local handling — the global hook is where you catch what slipped through.


Common Pitfall: Floating Promises

// BAD — "fire and forget" without error handling
async function handleClick() {
  saveData(); // returns a promise, but nobody awaits or catches it!
}

// GOOD — always handle the Promise
async function handleClick() {
  try {
    await saveData();
  } catch (err) {
    showError(err);
  }
}

// Also GOOD — if you intentionally fire-and-forget
function handleClick() {
  saveData().catch((err) => showError(err));
}

Error Handling in Async Code visual 1


Common Mistakes

  • Leaving Promises "floating" — calling an async function without await or .catch() means its rejection becomes invisible until it crashes the process or surfaces in a global handler far from the scene of the bug.
  • Putting a single top-level try/catch around a block where you actually needed per-step recovery — once any await throws, every later step is skipped, even ones that could have degraded gracefully.
  • Relying on the global unhandledrejection handler as your primary error strategy — it exists as a net for bugs, not as a substitute for local error handling.

Interview Questions

Q: What happens if a Promise rejects and there's no .catch() or try/catch?

It becomes an unhandled rejection. In browsers, it logs a warning and fires an unhandledrejection event. In Node.js (v15+), it terminates the process by default. Always handle Promise rejections.

Q: How do you handle errors with async/await?

Wrap await calls in try/catch blocks. The catch block receives the rejection reason. Use finally for cleanup. For per-operation handling, use multiple try/catch blocks or a utility wrapper that returns [error, result] tuples.

Q: What is a "floating Promise" and why is it dangerous?

A floating Promise is one that's neither awaited nor has a .catch() handler attached. If it rejects, the error is silently swallowed (or triggers an unhandled rejection). Always await or .catch() your Promises.

Q: How can you catch unhandled rejections globally?

In browsers: window.addEventListener("unhandledrejection", handler). In Node.js: process.on("unhandledRejection", handler). These are safety nets — always prefer explicit error handling.

Q: What happens if you forget try/catch around await?

The rejection escapes the function, bubbles up as a rejected Promise to the caller, and if nobody catches it anywhere up the chain, it becomes an unhandled rejection — logged in browsers, process-terminating in Node.js 15+.


Quick Reference — Cheat Sheet

ASYNC ERROR HANDLING — QUICK MAP

Primary patterns:
  async/await  -> try { await fn() } catch (e) {...} finally {...}
  promise      -> fn().then(...).catch(e => ...).finally(...)
  mid-chain    -> ...then(...).catch(recover).then(continue)
  go-style     -> const [err, val] = await to(fn())

Global safety nets (last resort):
  Browser:  window.addEventListener("unhandledrejection", h)
  Node.js:  process.on("unhandledRejection", h)

Rules:
  + try/catch every await that can reject
  + .catch() every chain you don't await
  + mid-chain .catch() to recover + continue
  - Never leave Promises floating (no await, no .catch)

Node 15+ default:
  Unhandled rejection -> process exits.

Previous: Promise Combinators — all, race, allSettled, any Next: Implicit Binding — How this Finds Its Object


This is Lesson 3.10 of the JavaScript Interview Prep Course — 14 chapters, 87 lessons.

On this page