JavaScript Interview Prep
Error Handling

Error Handling in Promises

.catch, Unhandled Rejections, and Global Nets

LinkedIn Hook

In Node.js 15 and later, a single unhandled promise rejection crashes your entire process. Default behavior. No warning.

One await fetch(url) without a .catch() somewhere up the chain, one 500 from a third-party API, and your server is gone. Your process manager restarts it. It happens again. And again. This is how "the site keeps crashing for no reason" tickets get written.

The fix is a handful of patterns every async-native JS engineer needs to know cold: where .catch() goes in a chain (position changes meaning), when to use throw vs return Promise.reject(), how Promise.all fails fast vs Promise.allSettled never failing, the Go-style "errors as values" pattern, and global nets for the ones that slip through — unhandledrejection in the browser, process.on("unhandledRejection") in Node.

In this lesson you will learn every one of them with runnable code.

Read the full lesson -> [link]

#JavaScript #InterviewPrep #Promises #AsyncAwait #NodeJS #ErrorHandling #CodingInterview #WebDevelopment


Error Handling in Promises thumbnail


What You'll Learn

  • How .catch() position changes behavior in a chain, and the difference between throw and Promise.reject()
  • Using try/catch with async/await, Promise.all vs Promise.allSettled, and the Go-style "errors as values" pattern
  • Global safety nets: window.onerror, addEventListener("error"), unhandledrejection, and process.on("unhandledRejection")

The Food Delivery Analogy

Think of promises like ordering food delivery. The .then() is what happens when the food arrives. The .catch() is your plan when the restaurant cancels. But here's the dangerous part: if you NEVER set up a plan for cancellation, your app just... silently starves. That's an unhandled rejection — the most common source of production crashes in Node.js.

.catch Placement Matters

The position of .catch() in a promise chain fundamentally changes behavior:

// Scenario 1: .catch at the END — catches errors from ALL previous .then()s
Promise.resolve("start")
  .then((val) => {
    throw new Error("Error in step 1");
  })
  .then((val) => {
    console.log("Step 2:", val); // SKIPPED
  })
  .then((val) => {
    console.log("Step 3:", val); // SKIPPED
  })
  .catch((err) => {
    console.log("Caught:", err.message); // "Caught: Error in step 1"
  });
// Scenario 2: .catch in the MIDDLE — catches errors ABOVE, chain continues BELOW
Promise.resolve("start")
  .then((val) => {
    throw new Error("Error in step 1");
  })
  .catch((err) => {
    console.log("Caught:", err.message); // "Caught: Error in step 1"
    return "recovered"; // Recovery value passed to next .then
  })
  .then((val) => {
    console.log("Step 2:", val); // "Step 2: recovered" — chain continues!
  });
// Scenario 3: Multiple .catch blocks for different sections
fetchUser()
  .then(processUser)
  .catch((err) => {
    // Catches fetchUser or processUser errors
    console.log("User error:", err.message);
    return getDefaultUser(); // Recovery
  })
  .then(sendEmail)
  .then(logResult)
  .catch((err) => {
    // Catches sendEmail or logResult errors
    console.log("Email error:", err.message);
  });

The catch-then-catch Chain

// Error in catch itself
Promise.reject("initial error")
  .catch((err) => {
    console.log("First catch:", err);
    throw new Error("error in catch!"); // Throwing INSIDE catch
  })
  .catch((err) => {
    console.log("Second catch:", err.message); // "error in catch!"
    // A .catch can catch errors from a previous .catch!
  });

Promise.reject vs throw

Inside a .then(), both throw and return Promise.reject() trigger the next .catch(). But there's a subtle difference:

// Both work the same inside .then():
Promise.resolve()
  .then(() => {
    throw new Error("thrown"); // Works
  })
  .catch((e) => console.log(e.message)); // "thrown"

Promise.resolve()
  .then(() => {
    return Promise.reject(new Error("rejected")); // Also works
  })
  .catch((e) => console.log(e.message)); // "rejected"

// BUT — outside of .then(), only Promise.reject works as expected:
function validate(input) {
  if (!input) {
    // throw new Error("invalid"); // This throws synchronously!
    return Promise.reject(new Error("invalid")); // Returns rejected promise
  }
  return Promise.resolve(input);
}

// With Promise.reject, the caller can always use .catch():
validate(null).catch((e) => console.log(e.message)); // "invalid"

async/await with try/catch

async/await lets you use regular try/catch for promise errors:

async function fetchUserData(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`);

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }

    const data = await response.json();
    return data;
  } catch (error) {
    if (error instanceof TypeError) {
      // Network error — fetch itself failed
      console.error("Network error:", error.message);
    } else {
      // HTTP error or JSON parse error
      console.error("API error:", error.message);
    }
    throw error; // Re-throw for caller to handle
  }
}

The "Error as Values" Pattern (Go-Style)

A popular pattern inspired by Go's error handling, avoiding try/catch entirely:

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

// Usage — clean, no try/catch nesting
async function createUser(userData) {
  const [validationErr, validated] = await to(validateInput(userData));
  if (validationErr) {
    return { error: "Invalid input: " + validationErr.message };
  }

  const [dbErr, user] = await to(database.create(validated));
  if (dbErr) {
    return { error: "Database failed: " + dbErr.message };
  }

  const [emailErr] = await to(sendWelcomeEmail(user.email));
  if (emailErr) {
    console.warn("Email failed, but user created"); // Non-critical
  }

  return { data: user };
}

This pattern makes each error check explicit and avoids deeply nested try/catch blocks. The await-to-js npm package implements this exact pattern.

Error Handling in Promise.all

// Promise.all — FAST FAILS on first rejection
async function fetchAllData() {
  try {
    const [users, posts, comments] = await Promise.all([
      fetch("/api/users").then((r) => r.json()),
      fetch("/api/posts").then((r) => r.json()),
      fetch("/api/comments").then((r) => r.json()),
    ]);
    return { users, posts, comments };
  } catch (error) {
    // Only the FIRST rejection is caught
    // Other promises still run but their results are discarded
    console.error("One request failed:", error.message);
  }
}

// Promise.allSettled — NEVER rejects, gives all results
async function fetchAllDataSafe() {
  const results = await Promise.allSettled([
    fetch("/api/users").then((r) => r.json()),
    fetch("/api/posts").then((r) => r.json()),
    fetch("/api/comments").then((r) => r.json()),
  ]);

  const data = {};
  const errors = [];

  results.forEach((result, index) => {
    const keys = ["users", "posts", "comments"];
    if (result.status === "fulfilled") {
      data[keys[index]] = result.value;
    } else {
      errors.push({ key: keys[index], reason: result.reason });
    }
  });

  return { data, errors };
}

Unhandled Rejection Events

Unhandled rejections are promises that reject without a .catch(). In Node.js 15+, they crash the process by default.

// DANGER — unhandled rejection
async function fetchData() {
  const res = await fetch("/api/data"); // If this rejects...
  return res.json();
}
fetchData(); // No .catch(), no try/catch — UNHANDLED REJECTION

// FIX — always handle rejections
fetchData().catch((err) => console.error("Failed:", err));

Global Error Handlers

// Browser — window.onerror (synchronous errors)
window.onerror = function (message, source, lineno, colno, error) {
  console.log("Global error:", message);
  console.log("Source:", source, "Line:", lineno);
  // Return true to prevent default browser error logging
  return true;
};

// Browser — addEventListener("error") — more capable
window.addEventListener("error", (event) => {
  console.log("Error event:", event.error);
  // Can capture errors that window.onerror misses (like resource load failures)
  // event.preventDefault() to suppress default logging
});

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

// Node.js — unhandled rejection (will crash in Node 15+)
process.on("unhandledRejection", (reason, promise) => {
  console.error("Unhandled Rejection at:", promise);
  console.error("Reason:", reason);
  // Log the error, then exit gracefully
  process.exit(1);
});

// Node.js — uncaught exception
process.on("uncaughtException", (error) => {
  console.error("Uncaught Exception:", error);
  // MUST exit — state may be corrupted
  process.exit(1);
});

window.onerror vs addEventListener("error") vs unhandledrejection

Featurewindow.onerroraddEventListener("error")unhandledrejection
Catches sync errorsYesYesNo
Catches promise rejectionsNoNoYes
Catches resource load errorsNoYes (with capture)No
Access to Error objectYes (5th param)Yes (event.error)N/A (event.reason)
Can prevent defaultReturn truepreventDefault()preventDefault()
Multiple handlersNo (overwritten)Yes (additive)Yes (additive)

Error Handling in Promises visual 1


Common Mistakes

  • Forgetting a .catch() at the end of a chain started from a fire-and-forget call — in Node.js 15+ the process crashes on that single unhandled rejection.
  • Using throw new Error(...) at the top of an async function's sibling (non-async) caller and assuming .catch() will see it — outside a .then() or an async function, throw is synchronous and escapes the promise world entirely. Return Promise.reject(...) from a non-async function instead.
  • Reaching for Promise.all when you actually need all results — one rejection discards the other fulfilled values. Use Promise.allSettled whenever partial failure is acceptable.

Interview Questions

Q: Does .catch() position in a promise chain matter?

Yes, critically. .catch() only handles rejections from promises ABOVE it in the chain. After .catch() returns a value, the chain continues with .then() handlers below it. This means you can create recovery points in a chain by placing .catch() in the middle.

Q: What's the difference between throw and Promise.reject() inside a .then()?

Inside .then(), they behave identically — both cause the returned promise to reject. The difference matters OUTSIDE .then(): throw is synchronous and will crash if not wrapped in try/catch, while return Promise.reject() returns a rejected promise that can be caught with .catch().

Q: What is an unhandled rejection and why is it dangerous?

An unhandled rejection is a promise that rejects without any .catch() handler. In Node.js 15+, it crashes the process by default. In browsers, it logs an error to the console. You should always handle rejections and use the unhandledrejection event as a safety net.

Q: How do you handle errors in Promise.all()?

Promise.all() fails fast — it rejects as soon as any single promise rejects, and you only get that one error. Use Promise.allSettled() when you want all results regardless of individual failures. Each result has {status: "fulfilled", value} or {status: "rejected", reason}.

Q: What's the difference between Promise.all and Promise.allSettled error handling?

Promise.all short-circuits: the moment any input promise rejects, the aggregate rejects and you only see the first error; the rest of the results are unreachable. Promise.allSettled never rejects — it resolves to an array of {status, value|reason} tuples so you can handle each success and failure independently.

Q: Explain the "error as values" pattern.

Inspired by Go's err, data := function() pattern, you wrap async calls in a utility function that returns [error, data] tuples instead of throwing. This avoids try/catch nesting and makes each error check explicit. The await-to-js npm package implements this pattern.

Q: How do you set up a global error handler in the browser?

Use window.addEventListener("error", ...) for synchronous errors (including resource load failures when you pass true for capture), and window.addEventListener("unhandledrejection", ...) for promise rejections that were never handled. Call event.preventDefault() inside the handler to suppress the default console log, and forward the error to your tracking service (Sentry, Datadog, etc.).

Q: What's the difference between window.onerror and window.addEventListener("error")?

window.onerror is a single-slot property — assigning a new handler overwrites the previous one, and it does not capture resource-load errors. addEventListener("error", ...) is additive (multiple listeners coexist) and, when registered with the capture phase, catches resource load failures like broken <img> or <script> URLs that window.onerror misses.


Quick Reference — Cheat Sheet

PROMISE ERROR HANDLING — QUICK MAP

.catch() position:
  at END     -> catches all errors above
  in MIDDLE  -> catches above; chain continues below with recovery value
  after .catch -> can chain another .catch (catches errors thrown in the first)

throw vs Promise.reject():
  inside  .then() / async fn  -> behave identically
  outside  .then()            -> throw is SYNC; use `return Promise.reject(e)`

Aggregation:
  Promise.all        -> FAILS FAST on first rejection (other results lost)
  Promise.allSettled -> NEVER rejects; [{status, value|reason}, ...]

async / await:
  wrap await in try/catch; re-throw for caller if you can't handle it
  Go-style: `const [err, data] = await to(promise)`  (await-to-js)

Global safety nets:
  Browser  window.onerror                         -> sync errors (single handler)
  Browser  addEventListener("error")              -> sync + resource loads (additive)
  Browser  addEventListener("unhandledrejection") -> async rejections
  Node.js  process.on("unhandledRejection")       -> crashes process in 15+
  Node.js  process.on("uncaughtException")        -> corrupted state, MUST exit

Quick-Fire (cross-lesson):
  1. What's the difference between Promise.all and Promise.allSettled error handling?
  2. What is an unhandled rejection?
  3. How do you set up a global error handler in the browser?
  4. What is the "error as values" pattern?
  5. Difference: window.onerror vs window.addEventListener("error")?

Previous: Error Types Next: DOM Manipulation


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

On this page