π§ 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.
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.
null.foo // Cannot read properties of null
undefined.map() // undefined is not a function
const x = 1; x = 2; // Assignment to constant
console.log(notDefined);
// notDefined is not defined
{ console.log(a); let a; }
// TDZ: Cannot access 'a' before init
const = 5; // parse-time: file dies
JSON.parse('{bad}') // runtime: CATCHABLE β
new Array(-1); // Invalid array length
(1.23).toFixed(500); // digits out of range
function f(){ f(); } f();
// Maximum call stack size exceeded
decodeURIComponent('%');
// URI malformed
Promise.any() when all promises reject.try { await Promise.any([p1, p2]); }
catch (e) {
e.errors // [Error, Error] β all of them
}
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! π
}
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);
finally's superpowers (and one footgun)
- Runs even after
returnβ guaranteed cleanup for locks, spinners, file handles,AbortControllers. - Footgun: a
returninsidefinallyoverrides the try's return value and swallows any in-flight exception. Neverreturnfromfinally. - ES2019: catch binding is optional β
catch { }when you don't need the error object (but pause before writing an empty catch β see below).
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('/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());
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
| Combinator | Resolves when⦠| Rejects when⦠| Error behavior |
|---|---|---|---|
Promise.all | ALL fulfill | first rejection | Fail-fast β one failure kills the lot (others keep running, results discarded) |
Promise.allSettled | always (never rejects) | never | Get every outcome: {status:'fulfilled'|'rejected', β¦} β inspect individually |
Promise.any | first fulfillment | ALL reject | Rejects with AggregateError containing all failures |
Promise.race | first settle (either way) | first settle is a rejection | Whatever 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()]);
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
});
| Handler | Catches | Doesn'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 equivalents | Best practice after uncaughtException: log, then restart the process β state is corrupt |
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.
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
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
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
ApiError, AuthError, NetworkError). The rest of the app switches on instanceof, never on raw shapes.retry/retryDelay for free.Error + stack + cause + user/release context. Never alert(err.stack), never log-only with a silent UI.π Summary β the Whole Guide on One Screen
"Where's the throw?" β tool picker
Fifteen-second recall cards
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).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..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.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.window 'error' for sync, 'unhandledrejection' for promises β send to monitoring with release + user context. They're telemetry, not error handling."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."