JavaScriptError Handling in Async Code

Error Handling in Async Code

Synchronous errors propagate up the call stack until a try / catch catches them. Asynchronous errors do not — they live inside promises and microtasks, and they will silently disappear if you do not actively handle them. This page is about catching async failures in the right place, and about the safety nets the runtime provides when you miss one.

Why async errors are different

A function that returns a promise has already returned by the time the error occurs. The stack that called it is long gone, so a try / catch wrapped around the call site sees nothing.

JS
function load() {
  return new Promise((_, reject) => {
    setTimeout(() => reject(new Error("boom")), 100);
  });
}

try {
  load();          // returns a pending promise — no error yet
} catch (err) {
  console.log("never runs");
}

The error has nowhere to go: load() returned a promise, the synchronous part is finished, and nobody is watching the promise. You need to attach a handler to that promise.

Catching with .catch

The simplest fix is a .catch at the end of every chain. Rejections skip every .then and land in the next .catch upstream.

JS
load()
  .then((value) => console.log("ok", value))
  .catch((err) => console.log("failed:", err.message));
.catch on the wrong line
A `.catch` only handles rejections from steps **before** it. Putting it before the last `.then` means new errors after it have nowhere to go. Put the `.catch` at the very end, or attach a second one.
try / catch around await

Inside an async function, the rejection is thrown as a normal exception — so the synchronous try / catch you already know works again. This is the cleanest pattern for most async code.

JS
async function loadUser(id) {
  try {
    const response = await fetch(`/api/user/${id}`);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return await response.json();
  } catch (err) {
    console.error("loadUser failed:", err);
    throw err;       // re-throw so callers know
  }
}
return vs return await inside try
Without `await`, a rejection escapes the surrounding `try` because the function has already returned. Use `return await` when you want the `try` to catch the rejection; use plain `return` outside `try` blocks.
Forgotten errors in chains

Three patterns silently swallow rejections. Memorise them and you will save hours of debugging:

1. Missing return inside .then

JS
Promise.resolve()
  .then(() => {
    fetch("/api/log");   // promise is dropped — its rejection vanishes
  })
  .catch((err) => console.log("never runs"));

2. async callback in forEach

JS
ids.forEach(async (id) => {
  await processOne(id);   // forEach throws away the returned promise
});
// errors in processOne never reach the surrounding try

3. Fire-and-forget call

JS
async function logOut() {
  fetch("/api/logout", { method: "POST" });   // missing await
}

In each case the rejection is unobserved. Fixes: return the inner promise, switch forEach to for...of or Promise.all(map), and await the fire-and-forget call.

The unhandledrejection safety net

If a promise rejects and no handler is attached by the time microtasks drain, the host fires an unhandledrejection event. Listening to it gives you a last chance to log, report or recover.

Browser

JS
window.addEventListener("unhandledrejection", (event) => {
  console.error("unhandled:", event.reason);
  // event.preventDefault();   // suppress the default console error
  reportToErrorTracker(event.reason);
});

Node.js

JS
process.on("unhandledRejection", (reason, promise) => {
  console.error("unhandled rejection at", promise, "reason:", reason);
});
Node terminates on unhandled rejection
Recent Node versions crash the process by default on an unhandled rejection. That is intentional — silent failures are worse than crashes. Use the event to *log* the cause, not to keep a broken process limping along.
Concurrent operations and partial failures

When you run several async tasks together, decide up front whether one failure should kill the batch (Promise.all) or whether you want each outcome reported (Promise.allSettled).

JS
const results = await Promise.allSettled(urls.map((u) => fetch(u)));

const failed = results
  .map((r, i) => ({ r, url: urls[i] }))
  .filter(({ r }) => r.status === "rejected");

if (failed.length) {
  console.warn(`${failed.length} of ${results.length} urls failed`);
}
Errors across module boundaries

Two pragmatic conventions help large async codebases stay debuggable:

  • Wrap, do not swallow. When you catch in a library boundary, attach context and re-throw — use new Error("message", { cause: original }) so the original stack survives.

  • Catch at the edge. Catch only where you can do something meaningful — log, retry, surface to the user. Interior code should rethrow.

  • Never catch an await just to ignore it unless you genuinely mean "I do not care if this fails" (and even then, log it).

Two rules cover most code
(1) Every `async` function eventually has a `try / catch` at its outer boundary. (2) Every `fetch`/`then` chain that escapes a function ends with a `.catch`. Follow those, and the `unhandledrejection` listener becomes a true safety net rather than a primary tool.