Concept
The beginner framing: try/catch lets you run code that might fail, and handle the failure gracefully instead of letting your program crash.
The precise mental model: a throw statement immediately stops normal execution and starts unwinding the call stack (see Execution Context), looking for the nearest enclosing catch block, skipping everything else in between, including the rest of the current function, any code after the call that threw, and every intermediate function on the stack that doesn't have its own try/catch. If nothing catches it anywhere up the stack, the error reaches the global level, which in a browser logs an uncaught error to the console (and can trigger window.onerror), and in Node.js crashes the process by default.
function risky() {
throw new Error("something broke");
console.log("never runs"); // unreachable, throw jumps past this immediately
}
try {
risky();
} catch (err) {
console.log("caught:", err.message); // "caught: something broke"
} finally {
console.log("always runs"); // runs whether an error was thrown or not
}Error objects, and why you should always throw them
throw "just a string"; // works, but LOSES the stack trace and standard error shape
throw new Error("proper error"); // has .message, .stack, .name, and every tool expects this shapeThrowing a plain string (or number, or object literal) instead of an Error instance is legal JavaScript, but it discards the automatically-captured stack trace and breaks the assumptions most error-handling tooling (logging services, instanceof Error checks, framework error boundaries) makes about what gets thrown.
Custom error classes: making error TYPE part of your API
class ValidationError extends Error {
constructor(field, message) {
super(message);
this.name = "ValidationError"; // otherwise inherited as generic "Error"
this.field = field; // custom errors can carry extra structured data
}
}
function validateAge(age) {
if (age < 0) throw new ValidationError("age", "Age cannot be negative");
}
try {
validateAge(-5);
} catch (err) {
Distinguishing error types (via custom classes and instanceof) lets calling code handle different failure categories differently, a ValidationError might show a form field hint, while a NetworkError might trigger a retry, and code that only knows how to handle one type can safely re-throw anything else.
Error chaining with cause (ES2022)
async function loadUserProfile(id) {
try {
return await fetchUser(id);
} catch (err) {
throw new Error(`Failed to load profile for user ${id}`, { cause: err }); // preserves the ORIGINAL error
}
}Without cause, re-throwing a more descriptive error at a higher level traditionally discarded the original low-level error entirely (the network failure reason, the exact stack trace), cause lets you add context at each layer while preserving the full chain back to the root failure, which console.error and most modern tooling display automatically.
Try It
This function's error handling has a real, common bug. Find it before checking the solution.
async function saveUser(user) {
try {
await validateUser(user);
await persistUser(user);
return { success: true };
} catch (err) {
console.log("save failed");
}
}
const result = await saveUser(badUser);
console.log(result.success); // TypeError!Solution
The catch block swallows the error and returns nothing (undefined) instead of returning a consistent failure shape, the caller has no way to know the operation failed except by the absence of .success, which then throws trying to read .success off undefined.
async function saveUser(user) {
try {
await validateUser(user);
await persistUser(user);
return { success: true };
} catch (err) {
console.error("save failed:", err);
return { success: false
Implement It Yourself
Implement a retry utility that re-attempts a failing async operation a fixed number of times before giving up, a genuinely useful, common real-world error-handling pattern:
async function retry(fn, attempts = 3, delayMs = 500) {
let lastError;
for (let i = 0; i < attempts; i++) {
try {
return await fn(); // success, return immediately, no more attempts needed
} catch (err) {
lastError = err;
console.log(`attempt ${i + 1} failed: ${err.message}`);
if (i < attempts - 1) {
await
This pattern is standard for handling transient failures (flaky network requests, rate-limited APIs), the cause option ensures that if all retries are exhausted, the final thrown error still carries the original failure reason for debugging.
In React
Error Boundaries are React's built-in mechanism for catching errors during rendering, in lifecycle methods, and in constructors of the component tree below them, conceptually the same "catch at a boundary, handle gracefully" idea as try/catch, but implemented as a class component with static getDerivedStateFromError() (to render a fallback UI) and componentDidCatch() (to log the error), since there's no hook-based equivalent yet. Critically, Error Boundaries do NOT catch errors in event handlers, async code, or setTimeout callbacks, those need ordinary try/catch (or .catch()) exactly as in any other JavaScript, because they don't happen during React's render phase at all. This is a very common point of confusion: wrapping an app in an Error Boundary and then being surprised that a fetch().catch()-less network failure inside a click handler still crashes with an unhandled error, uncaught by the boundary.
Common Mistakes
1. Catching an error and doing nothing with it (silent failure)
try {
riskyOperation();
} catch (err) {
// empty catch block, the error just... vanishes
}An empty catch block is almost always worse than letting the error propagate, at minimum, log it; ideally, handle it meaningfully or re-throw it if this code genuinely doesn't know how to recover.
2. Catching too broadly and hiding bugs
async function loadData() {
try {
const config = await fetchConfig();
const data = await fetchData(config);
return process(data); // a BUG here (e.g. a typo) is ALSO caught and misreported as "load failed"
} catch (err) {
return { error: "Failed to load data" }; // masks the REAL, possibly unrelated, error
}
}Wrapping large blocks of unrelated logic in one try/catch makes it impossible to tell a genuine, expected failure (network down) from an unrelated bug (a typo causing a TypeError), both get reported identically. Keep try blocks scoped to the specific operation that can genuinely fail in an expected way.
3. Forgetting that finally runs even after a return in try or catch
function example() {
try {
return "from try";
} finally {
console.log("finally runs"); // logs BEFORE the function actually returns
}
}This is usually harmless, but a finally block that ALSO contains a return will silently override the try/catch's return value, a genuinely confusing, best-avoided pattern.
Best Practices
- Throw
Errorinstances (or subclasses), never plain strings or objects, you lose the stack trace and break tooling expectations otherwise. - Create custom error classes for distinct failure categories your application actually needs to handle differently, don't over-engineer a hierarchy for failures you treat identically anyway.
- Use the
causeoption when re-throwing a more specific/contextual error, to preserve the original failure for debugging. - Keep
tryblocks narrowly scoped to the operation that can actually fail, rather than wrapping large swaths of unrelated logic. - Never leave a
catchblock empty, log at minimum, even if you deliberately choose to swallow the error.
Performance Tips
try/catchitself has effectively zero overhead in modern engines when no error is thrown, V8 optimizes the non-throwing path aggressively. The cost only shows up when an error IS actually thrown, since constructing anErrorcaptures a full stack trace, which is a genuinely non-trivial operation.- Avoid using exceptions for routine, expected control flow (e.g., throwing to signal "not found" in a hot lookup loop), reserve
throwfor genuinely exceptional situations, and use return values (null, a result object,undefined) for expected "not found" or "invalid" cases that happen routinely. - Unhandled promise rejections and uncaught exceptions in Node.js can, depending on configuration, crash the entire process, always install a top-level
process.on('unhandledRejection', ...)/process.on('uncaughtException', ...)handler in production Node services as a last-resort safety net, logging the error before a controlled shutdown, rather than letting the process die with no diagnostic trail.
