πŸ›‘οΈ Error Handling β€” JavaScript & React

Error types Β· throw Β· try/catch/finally Β· Promises Β· async/await Β· global handlers Β· React Error Boundaries (and their limits)

🧠 The Mental Model

An error in JavaScript is just an object (usually an instance of Error) that gets thrown. Throwing interrupts normal execution and the engine starts unwinding the call stack, looking for the nearest enclosing catch. If nothing catches it, it becomes an uncaught error β€” the script (or the current task) dies, and only global handlers can observe it.

throw new Error("boom") β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Is there an enclosing try...catch β”‚ β”‚ ON THE CURRENT CALL STACK? β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ YES β”‚ β”‚ NO β–Ό β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ catch runs. β”‚ β”‚ UNCAUGHT ERROR β”‚ β”‚ Execution β”‚ β”‚ β€’ sync β†’ window 'error' β”‚ β”‚ continues β”‚ β”‚ β€’ promise β†’ 'unhandled- β”‚ β”‚ after block β”‚ β”‚ rejection' β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β€’ script/task terminates β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ KEY INSIGHT: catch only works on the SAME call stack. Async callbacks run on a NEW stack later β€” the old try/catch is gone.
πŸ’‘ The one sentence that organizes everything

Every error-handling tool answers the same question β€” "who is on the call stack when this throws?" Sync code β†’ try/catch. Promise code β†’ .catch() / try/await. Nobody β†’ global handlers. React render β†’ Error Boundary.

The two big families

πŸ“ Programmer errors (bugs)

Broken code: calling undefined as a function, typos, wrong types. You don't "handle" these β€” you fix them. Catching and continuing hides the bug.

🌩️ Operational errors (expected failures)

Things that fail even in correct code: network down, 404/500, invalid user input, quota exceeded, timeout. These you handle β€” retry, fallback, message to the user.

🏷️ The Built-in Error Types

All inherit from Error. Every instance carries three key properties: name, message, and (non-standard but universal) stack.

Error base: name Β· message Β· stack Β· cause TypeError ReferenceError SyntaxError RangeError URIError EvalError (legacy) AggregateError wraps many errors β€” thrown by Promise.any()
πŸ”΄ TypeError
A value is not of the expected type / operation is invalid on it. The most common runtime error.
null.foo          // Cannot read properties of null
undefined.map()   // undefined is not a function
const x = 1; x = 2; // Assignment to constant
🟠 ReferenceError
You referenced a variable that doesn't exist in scope (or is in the TDZ).
console.log(notDefined);
// notDefined is not defined
{ console.log(a); let a; }
// TDZ: Cannot access 'a' before init
🟣 SyntaxError
Code can't be parsed. Thrown at parse time β€” try/catch around it can't help (the file never runs). Catchable only for dynamic parsing.
const = 5;        // parse-time: file dies
JSON.parse('{bad}') // runtime: CATCHABLE βœ…
πŸ”΅ RangeError
A numeric value is outside the allowed range β€” including infinite recursion's stack overflow.
new Array(-1);      // Invalid array length
(1.23).toFixed(500); // digits out of range
function f(){ f(); } f();
// Maximum call stack size exceeded
🟦 URIError
Malformed input to URI functions. Rare, but real when handling query strings.
decodeURIComponent('%');
// URI malformed
🟒 AggregateError
One error wrapping many β€” thrown by Promise.any() when all promises reject.
try { await Promise.any([p1, p2]); }
catch (e) {
  e.errors // [Error, Error] β€” all of them
}
⚠️ Interview nuance: SyntaxError has two lives

Parse-time syntax errors (bad code in the file) kill the whole script before any line runs β€” uncatchable. Runtime syntax errors from parsing data (JSON.parse, new Function()) are normal throwable errors you catch every day. Saying this distinction out loud is instant senior signal.

🎯 throw & Custom Errors

throw can throw any value β€” but you should always throw Error instances, because only they carry a stack trace, and tooling (Sentry, logs) depends on it.

❌ Throwing values

throw 'something failed';
throw { code: 500 };
// No stack trace. instanceof
// checks impossible. Logs are
// useless: "Uncaught #<Object>"

βœ… Throwing Error instances

throw new Error('Payment failed', {
  cause: originalError  // ES2022: keep the chain
});
// name + message + stack + cause βœ…

Custom error classes β€” model your domain

Extend Error so callers can react differently to different failures using instanceof instead of string-matching messages.

class ApiError extends Error {
  constructor(message, status, { cause } = {}) {
    super(message, { cause });
    this.name = 'ApiError';   // otherwise logs say "Error"
    this.status = status;
  }
}
class ValidationError extends Error {
  constructor(message, field) { super(message); this.name = 'ValidationError'; this.field = field; }
}

// Caller reacts per type β€” no message string-matching:
try {
  await saveUser(form);
} catch (err) {
  if (err instanceof ValidationError) highlightField(err.field);
  else if (err instanceof ApiError && err.status === 401) redirectToLogin();
  else throw err;   // unknown β†’ don't swallow. Re-throw! πŸ”‘
}
βœ… The re-throw rule

Catch only what you can meaningfully handle. Anything you don't recognize, re-throw β€” a swallowed unknown error is a silent bug in production. error.cause lets you wrap-and-rethrow without losing the original: throw new ApiError('Sync failed', 502, { cause: err }).

🧰 try / catch / finally

The synchronous workhorse. Three blocks, each with a precise job:

try {
  // 1. Code that might throw
  const data = JSON.parse(raw);
  process(data);
} catch (err) {
  // 2. Runs ONLY if try threw. Receives the thrown value.
  if (err instanceof SyntaxError) showToast('Invalid file format');
  else throw err;
} finally {
  // 3. Runs ALWAYS β€” success, throw, even after a return.
  spinner.hide();       // cleanup belongs here
}

The trap everyone hits: try/catch does NOT catch async callbacks

❌ Catches nothing

try {
  setTimeout(() => {
    throw new Error('boom');
  }, 1000);
} catch (e) {
  // NEVER runs. The callback executes
  // on a FRESH call stack a second
  // later β€” this try is long gone.
}

βœ… Catch where the throw happens

setTimeout(() => {
  try {
    risky();
  } catch (e) {
    handle(e);   // same stack βœ…
  }
}, 1000);
WHY: the event loop connection try { 1 second later, NEW task: setTimeout(cb, 1000) ──► β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” } catch { } β”‚ cb() β”‚ ◄── fresh, empty call stack β–² β”‚ throw! β”‚ no try/catch above it └── this stack finished β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β†’ goes straight to long ago window 'error'

finally's superpowers (and one footgun)

  • Runs even after return β€” guaranteed cleanup for locks, spinners, file handles, AbortControllers.
  • Footgun: a return inside finally overrides the try's return value and swallows any in-flight exception. Never return from finally.
  • ES2019: catch binding is optional β€” catch { } when you don't need the error object (but pause before writing an empty catch β€” see below).
🚫 The cardinal sin: the silent catch

catch (e) {} β€” an empty catch turns every future bug in that block invisible. If you truly must ignore, leave a comment saying why, and log it anyway in dev. The default should be: handle it, or re-throw it, or at minimum console.error + report it.

⛓️ Errors in Promises

A rejected promise is the async equivalent of a throw. Inside a promise chain, errors propagate down the chain β€” skipping every .then β€” until they meet a .catch.

fetch(url) β”‚ rejects (network down) β–Ό .then(parse) ── SKIPPED ──┐ β”‚ β”‚ rejection "falls through" .then(render) ── SKIPPED ─── every .then until... β”‚ β”‚ .catch(showError) β—„β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ...caught here βœ… β”‚ (returns normally β†’ chain is "healed") β–Ό .then(cleanup) ── RUNS β€” the chain recovered
fetch('/api/user')
  .then(res => {
    if (!res.ok) throw new ApiError('Request failed', res.status);
    return res.json();   // ⚠️ fetch does NOT reject on 404/500 β€” only network failure!
  })
  .then(user => render(user))
  .catch(err => showError(err))     // catches network fail, bad JSON, render throw β€” anything above
  .finally(() => spinner.hide());
⚠️ Three promise gotchas interviewers love

1. fetch only rejects on network failure β€” a 500 response resolves fine; you must check res.ok yourself.
2. .catch position matters β€” it only catches errors from above it in the chain. A throw in a .then after the catch is unhandled.
3. .then(ok, fail) β‰  .then(ok).catch(fail) β€” the two-argument form's fail can't catch a throw inside its own ok sibling. Prefer .catch.

Combinators: how each one fails

CombinatorResolves when…Rejects when…Error behavior
Promise.allALL fulfillfirst rejectionFail-fast β€” one failure kills the lot (others keep running, results discarded)
Promise.allSettledalways (never rejects)neverGet every outcome: {status:'fulfilled'|'rejected', …} β€” inspect individually
Promise.anyfirst fulfillmentALL rejectRejects with AggregateError containing all failures
Promise.racefirst settle (either way)first settle is a rejectionWhatever finishes first wins β€” classic for timeouts
// "Load the dashboard even if one widget fails" β†’ allSettled, not all
const results = await Promise.allSettled([getUser(), getOrders(), getAds()]);
const [user, orders, ads] = results.map(r => r.status === 'fulfilled' ? r.value : null);

⏳ async / await

The gift of async/await: a rejected promise becomes a throw at the await β€” so plain try/catch works for async code again.

async function loadUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) throw new ApiError('Load failed', res.status);
    return await res.json();
  } catch (err) {
    if (err instanceof ApiError && err.status === 404) return null; // expected: handle
    throw err;                                     // unexpected: propagate
  } finally {
    stopLoading();
  }
}

The two classic async traps

❌ Trap 1: forgot to await

try {
  saveDraft();   // no await!
} catch (e) {
  // NEVER catches. The promise
  // rejects later, outside this
  // try β†’ unhandledrejection.
}

❌ Trap 2: return vs return await

async function f() {
  try {
    return risky();        // ❌ escapes try
    // return await risky(); // βœ… caught here
  } catch (e) { handle(e); }
}

Inside a try, always return await β€” without await, the promise leaves the function unresolved and its rejection bypasses your catch.

Parallel + error handling done right

// ❌ Sequential awaits β€” slow, and first failure aborts silently mid-way
const user = await getUser();
const posts = await getPosts();

// βœ… Parallel, all-or-nothing (fail fast):
const [user, posts] = await Promise.all([getUser(), getPosts()]);

// βœ… Parallel, partial success allowed:
const [u, p] = await Promise.allSettled([getUser(), getPosts()]);
🚫 The floating promise

Any promise you neither await nor .catch() is a floating promise β€” its rejection is invisible until it explodes as unhandledrejection. Lint it away with @typescript-eslint/no-floating-promises. For deliberate fire-and-forget: void doThing().catch(report);

🌍 Global Handlers β€” the Last Line of Defense

Whatever slips through every local handler lands here. These are for logging/telemetry and graceful degradation β€” not a substitute for local handling.

// 1️⃣ Uncaught SYNCHRONOUS errors (and resource load errors with capture)
window.addEventListener('error', (event) => {
  // event.message, event.filename, event.lineno, event.error (the Error object)
  sendToSentry(event.error);
});

// 2️⃣ Unhandled PROMISE rejections β€” the async counterpart
window.addEventListener('unhandledrejection', (event) => {
  sendToSentry(event.reason);   // the rejection value
  event.preventDefault();        // optional: suppress console noise
});
HandlerCatchesDoesn't catch
window 'error'Uncaught sync throws; <img>/<script> load failures (capture phase)Promise rejections; errors inside cross-origin scripts show as "Script error." without CORS + crossorigin attr
'unhandledrejection'Promise rejections nobody .catch-ed (fires after microtask checkpoint)Sync throws; rejections that DO get handled later fire rejectionhandled
Node: process.on('uncaughtException' / 'unhandledRejection')Server-side equivalentsBest practice after uncaughtException: log, then restart the process β€” state is corrupt
πŸ’‘ Production reality

These two listeners are exactly how monitoring SDKs (Sentry, Datadog, Bugsnag) auto-capture your errors. Rolling your own? Wire both, include release/version and user context, and sample/dedupe so one render-loop bug doesn't send 50,000 events.

βš›οΈ React Error Boundaries

By default, an uncaught error during React rendering unmounts the entire tree β€” white screen. An Error Boundary is a component that catches errors from the tree below it during render, and shows a fallback UI instead of killing the whole app.

<App> <App> βœ… still alive β”œβ”€β”€ <Header /> β”œβ”€β”€ <Header /> βœ… └── <ErrorBoundary> └── <ErrorBoundary> └── <ProductList> πŸ’₯ └── ⚠️ <Fallback UI /> └── <Row /> throws in render WITHOUT boundary: whole tree unmounts β†’ blank page WITH boundary: blast radius = just that subtree

The API β€” still class-only (by design)

class ErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError(error) {
    return { hasError: true };          // render phase: switch to fallback
  }

  componentDidCatch(error, info) {
    logToService(error, info.componentStack);  // commit phase: side effects OK
  }

  render() {
    if (this.state.hasError) return this.props.fallback;
    return this.props.children;
  }
}

// Usage β€” granular boundaries per feature, not one giant one:
<ErrorBoundary fallback={<WidgetError />}>
  <RevenueChart />
</ErrorBoundary>

There is no hook equivalent β€” a function component can't be a boundary. In practice everyone uses the tiny react-error-boundary package: <ErrorBoundary FallbackComponent={…} onReset={…}> plus a useErrorBoundary() hook to forward async errors into the nearest boundary.

What boundaries catch β€” and famously don't

βœ… CAUGHT by a boundary

  • Throws during render of any descendant
  • Errors in descendants' lifecycle methods
  • Errors in constructors of children
  • Errors thrown while rendering the fallback of a nested boundary (bubble up to the next one)

❌ NOT caught β€” know all five

  • Event handlers (onClick…) β€” not render; use try/catch
  • Async code β€” setTimeout, fetch/promises, await
  • SSR errors (server rendering)
  • Errors in the boundary itself (bubble to parent boundary)
  • Errors above it in the tree β€” it only protects descendants
⚠️ Why aren't event handlers & async caught? (the "why" interviewers want)

A boundary works by React catching throws while it is rendering the tree β€” it's essentially a try/catch around the render phase. Event handlers and async callbacks run later, on their own call stacks, when React isn't rendering β€” same reason a plain try/catch can't catch a setTimeout throw. React isn't on the stack, so it never sees it.

Handling what boundaries miss

// Event handler β€” plain try/catch:
function handleBuy() {
  try { purchase(cart); }
  catch (e) { setError(e); }        // render an error state yourself
}

// Async β€” catch it, then FORWARD it into the boundary via state:
const { showBoundary } = useErrorBoundary();   // react-error-boundary
useEffect(() => {
  loadData().catch(err => showBoundary(err));  // re-throws during render β†’ caught βœ…
}, []);

The trick behind showBoundary (or the DIY version β€” setState(() => { throw err })): move the throw back into the render phase, where the boundary can see it. Data libraries do this for you β€” React Query's throwOnError, and React Router's errorElement β€” and Suspense pairs with boundaries: promise pending β†’ Suspense fallback; promise rejected β†’ nearest error boundary.

Pros, cons & judgment

βœ… Pros

  • Blast-radius control β€” one broken widget β‰  dead app
  • Declarative fallback UI + one place to log render errors
  • Composable: nest per route / per feature / per widget
  • Recovery via reset (onReset, resetKeys) β€” "Try again" without reload

❌ Cons / limitations

  • Misses events, async, SSR β€” needs the forwarding patterns above
  • Class-only API; hook story delegated to a 3rd-party lib
  • Fallback loses all subtree state (remounts on reset)
  • One giant root boundary = whole app replaced by one error page β€” granularity is on you
βœ… Placement strategy (the senior answer)

Three layers: a root boundary as the crash-page of last resort β†’ a boundary per route (React Router's errorElement) β†’ boundaries around independent widgets (each dashboard card). Errors degrade the smallest possible unit, and each layer logs to monitoring.

πŸ—οΈ Production Patterns

1
Normalize at the edges. Wrap fetch/SDK calls in one API client that converts every failure into your domain errors (ApiError, AuthError, NetworkError). The rest of the app switches on instanceof, never on raw shapes.
2
Handle expected, propagate unexpected. 404 β†’ show empty state. Validation β†’ highlight field. Unknown β†’ re-throw and let the boundary + Sentry see it. Swallowing unknowns is how bugs become "it just doesn't work sometimes."
3
Retry with judgment. Retry only idempotent, transient failures (network, 502/503) with exponential backoff + jitter and a cap. Never blind-retry a POST payment. React Query gives you retry/retryDelay for free.
4
Two audiences per error. The user gets a friendly, actionable message ("Couldn't save β€” check connection, then Retry"). Monitoring gets the full Error + stack + cause + user/release context. Never alert(err.stack), never log-only with a silent UI.
5
Fail fast in dev, degrade gracefully in prod. Assertions/invariants that throw loudly in development; boundaries, fallbacks and telemetry in production. Same bug, two very different presentations.

πŸ“‹ Summary β€” the Whole Guide on One Screen

"Where's the throw?" β†’ tool picker

WHERE DOES THE ERROR HAPPEN? USE THIS ───────────────────────────── ────────────────────────────── Synchronous code ──► try / catch / finally Promise chain ──► .catch() (+ .finally cleanup) async function ──► try { await … } catch (return await!) Callback (setTimeout, listener) ──► try/catch INSIDE the callback Many parallel promises ──► all (fail-fast) / allSettled (partial) React render / lifecycle ──► Error Boundary React event handler ──► try/catch in the handler + error state React async (fetch/effect) ──► catch β†’ showBoundary(err) / throwOnError Anything that escaped everything ──► window 'error' + 'unhandledrejection' β†’ Sentry

Fifteen-second recall cards

Error types?
TypeError (wrong type/null access) Β· ReferenceError (undefined variable/TDZ) Β· SyntaxError (unparseable β€” parse-time uncatchable, JSON.parse catchable) Β· RangeError (bad numeric value, stack overflow) Β· URIError (malformed URI) Β· AggregateError (many-in-one, Promise.any).
Golden throw rules?
Always throw Error instances (stack!). Custom classes + instanceof for branching. Chain context with { cause }. Catch what you can handle, re-throw the rest. Never leave an empty catch.
Why can't try/catch grab async?
Catch works per call stack. Async callbacks run later on a fresh stack β€” your try already returned. That single fact explains the setTimeout trap, the missing-await trap, and why boundaries miss handlers/async.
Promise essentials?
Rejections skip .thens until a .catch; the chain heals after it. fetch doesn't reject on 404/500 β€” check res.ok. all=fail-fast, allSettled=every outcome, any=first success (else AggregateError), race=first settle. Inside try: return await, not return.
Error Boundaries in one breath?
Class component with getDerivedStateFromError (fallback) + componentDidCatch (log). Catches render/lifecycle/constructor errors of descendants. Misses: event handlers, async, SSR, itself, above-tree. Fix: try/catch in handlers; forward async via useErrorBoundary/throwOnError. Place at root + route + widget.
Last line of defense?
window 'error' for sync, 'unhandledrejection' for promises β†’ send to monitoring with release + user context. They're telemetry, not error handling.
🎀 The 30-second interview answer

"Errors are thrown objects that unwind the call stack. Sync code: try/catch/finally; promises: .catch; async/await turns rejections back into throws. The unifying rule is that catching happens per call stack β€” which is why callbacks need their own try/catch and why React error boundaries only catch render-phase errors of their subtree, not event handlers or async. I handle expected failures locally with typed custom errors, forward async errors into boundaries, keep boundaries granular (root/route/widget), and let global error + unhandledrejection handlers ship everything else to monitoring."

Error Handling Guide | Skumar Frontend Deep Dives © 2026