Languages

JavaScript Error Handling: From try/catch to Global Fallbacks and Retry

A practical catalog for backend and full-stack JS: catching sync and async errors, Promise unhandledrejection, global fallbacks, and exponential-backoff retries.

By LaoHand Team·7 min read·Updated 2026-09-06

Sync and Async Run on Different Tracks: Know the Two Catches

try/catch only catches synchronous throws and an await of a rejected Promise. A throw inside a setTimeout callback, or a Promise rejected without being awaited, escapes try/catch.

Inside an async function you can either wrap await in try/catch or return the promise for an outer .catch. The choice is whether to recover locally (try/catch) or push it to the caller (return the promise).

A classic trip: wrapping a whole await sequence in try/catch while one of the awaited ops is actually not awaited — that error leaks to unhandledrejection.

// 异步里的 throw 不会被外层 try/catch 拦住:
try {
  setTimeout(() => { throw new Error("boom"); }, 0);
} catch (e) {
  console.log("caught");  // 永远不会到
}

// 正确接住异步错误:await 必须真的被 await
try {
  await risky();
} catch (e) {
  console.log("caught", e.message);
}

// 或者把 promise 交给调用方
function loadUser(id) {
  return fetchUser(id).then(normalize);
}
// loadUser(...).catch(...) by the caller

Give Enough Context: Do Not Destroy the Original Error

A common sin is logging err as a mere string "something failed", losing the stack and cause. Ten minutes into a real outage everyone wants the stack, not a dangling sentence.

Modern runtimes support an Error cause chain (new Error("context", { cause: original })), linking "failed here" with "root cause there", useful when debugging nested dependencies.

When a validation library throws, it usually throws a fresh Error; be careful not to swallow the original. Pass the original as cause, or at least log it, instead of formatting it away.

async function fetchProfile() {
  try {
    const blob = await http.get("/me");
    return parseProfile(blob);
  } catch (err) {
    // 保留原始错误为 cause,外面可看堆栈
    throw new Error("failed to load profile", { cause: err });
  }
}

try {
  await fetchProfile();
} catch (err) {
  console.error(err.message);        // failed to load profile
  console.error(err.cause?.message); // 底层根因
  console.error(err.cause?.stack);
}

Mistake: Swallowing the Error Into an Empty Log

Some catch blocks only console.error("oops") or sit empty. On a real incident there is no stack, no context, nothing reproducible.

A sneakier variant returns a "fake success" value (say return null that nobody checks) and the downstream blows through null-reference errors.

The fix rule: catch and translate to an explicit failure only if you can recover; otherwise rethrow with a full cause. Never let one error decay into twenty unrelated downstream cries.

// 错误示范:吞掉并伪造成功
async function getScore() {
  try {
    return await fetchScore();
  } catch {
    return null;  // 下游 if (!score) throw 一通乱
  }
}

// 修复:区分“能恢复”与“直接暴露”
async function getScore() {
  try {
    return await fetchScore();
  } catch (err) {
    // 有限次数降级可恢复,否则带 cause 重新抛出
    logger.error({ err, msg: "fetchScore failed, falling back to 0" });
    return cachedScore() ?? 0;
  }
}

Global Fallbacks: Catching Runaways via process and Browser Events

No mechanism is flawless; runaways escape to unhandledRejection / uncaughtException. Global hooks log them to monitoring instead of vanishing, and on Node they decide whether the process must exit.

In Node, process.on("uncaughtException") and process.on("unhandledRejection") gather the tail; note the process state may be untrustworthy after a caught uncaughtException, so log, alert, and exit cleanly for the orchestrator to restart.

On the browser, window.onerror and unhandledrejection pair with Sentry or self-built reporting to pull back even errors swallowed on the user side.

// Node 末端兜底
process.on("uncaughtException", (err) => {
  reportToMonitoring(err);
  console.error("FATAL", err);
  process.exit(1);          // 状态不可信,交给编排重启
});

process.on("unhandledRejection", (reason) => {
  reportToMonitoring(reason);
});

// 浏览器
window.addEventListener("error", (e) => reportToMonitoring(e.error));
window.addEventListener("unhandledrejection", (e) =>
  reportToMonitoring(e.reason),
);

Retry with Backoff: Transient Failure Is Not Failure

Network glitches, downstream 5xx, and pooled-connection pressure are usually transient. A sanely sized retry smooths out occasional failures, but blind infinite retries amplify a transient storm into an avalanche.

Exponential backoff plus random jitter keeps many clients from retrying in sync and crashing into each other; cap with a totalTimeout or maxAttempts; only retry idempotent or safely-replayable requests (evaluate POSTs).

Centralize the retry policy in a shared HTTP wrapper instead of re-implementing per call site, for consistency, observability (log each retry reason), and optional graceful degradation.

async function withRetry(fn, { maxAttempts = 4, base = 200 } = {}) {
  let lastErr;
  for (let i = 0; i < maxAttempts; i++) {
    try {
      return await fn();
    } catch (err) {
      lastErr = err;
      const shouldStop = err.retryable === false || i === maxAttempts - 1;
      if (shouldStop) break;
      const delay = base * 2 ** i + Math.floor(Math.random() * base); // jitter
      console.warn(`attempt ${i + 1} failed, retry in ${delay}ms`, err.message);
      await new Promise((r) => setTimeout(r, delay));
    }
  }
  throw lastErr;
}

const data = await withRetry(() => http.get("/api/orders"));

Official References

Each command links to its official documentation below, so you can verify the latest usage and read deeper.