Error Handling Patterns
Knowing the try/catch mechanics is only half the job — the harder skill is deciding where errors should be caught, how a function should report failure, and what the user sees when something breaks. Good error handling is an architecture choice, not a sprinkling of catch blocks. This page covers the patterns that scale: validating inputs up front, drawing clear try/catch boundaries, choosing between throwing and returning result objects, installing global safety nets, and degrading gracefully so a single failure never freezes the whole app.
Validate and fail fast
The cheapest error to handle is the one you reject immediately. Check arguments and preconditions at the top of a function and throw straight away, before any partial work happens. A failure raised at the boundary points directly at the caller’s mistake; a failure that surfaces three layers deep forces you to reconstruct what went wrong.
function transfer(account, amount) {
if (!account) throw new TypeError("account is required");
if (!Number.isFinite(amount) || amount <= 0) {
throw new RangeError(`amount must be a positive number, got ${amount}`);
}
// From here down, every value is known-good.
account.balance -= amount;
return account.balance;
}
Failing fast keeps the rest of the function free of defensive if checks, because every value below the guard clauses is already trusted. It also produces precise, actionable messages instead of a vague Cannot read properties of undefined further along.
Validate at trust boundaries — public function arguments, network responses, and user input. Don’t re-validate the same data deep inside internal helpers; that just scatters the same check everywhere.
Define try/catch boundaries
Don’t wrap every call in its own try/catch. Instead, pick deliberate boundaries — a request handler, a UI event handler, a background job — where an error can be turned into a meaningful response. Inner functions stay clean and simply throw; the boundary catches, logs, and decides the outcome.
async function handleRequest(req, res) {
try {
const user = await loadUser(req.params.id); // may throw
const orders = await loadOrders(user.id); // may throw
res.json({ user, orders });
} catch (err) {
console.error("request failed:", err);
res.status(500).json({ error: "Something went wrong" });
}
}
Here loadUser and loadOrders contain zero error handling — they throw on failure and trust the boundary above to deal with it. This keeps each layer focused: domain logic computes, the boundary translates failures into HTTP responses.
Throw vs. return a result object
Throwing is right for exceptional conditions — a missing config file, a network outage, a bug. But for expected failures that the caller will routinely branch on, throwing forces awkward control flow. An alternative is to return a tagged result object (the Result / discriminated-union pattern), making success and failure explicit values rather than control-flow jumps.
function parseAge(input) {
const n = Number(input);
if (!Number.isInteger(n) || n < 0) {
return { ok: false, error: "Age must be a non-negative integer" };
}
return { ok: true, value: n };
}
const result = parseAge("forty");
if (result.ok) {
console.log(`Parsed ${result.value}`);
} else {
console.log(`Rejected: ${result.error}`);
}
Output:
Rejected: Age must be a non-negative integer
| Approach | Best for | Trade-off |
|---|---|---|
throw | Bugs, unrecoverable or rare failures | Easy to forget to catch; cost on deep stacks |
| Result object | Expected, frequent failures (validation, lookups) | Caller must check ok every time |
A useful rule of thumb: throw when continuing makes no sense, return a result when “it didn’t work” is a normal, anticipated answer.
Install global handlers
No matter how careful you are, some error will slip past every local catch. Global handlers are the last line of defense — they let you log the failure, report it to monitoring, and avoid a silent dead app.
In the browser, listen for both synchronous errors and unhandled promise rejections:
window.addEventListener("error", (event) => {
reportToService({ message: event.message, source: event.filename });
});
window.addEventListener("unhandledrejection", (event) => {
reportToService({ message: "Unhandled rejection", reason: event.reason });
event.preventDefault(); // suppress the default console warning
});
In Node.js the equivalents live on process:
process.on("unhandledRejection", (reason) => {
console.error("Unhandled rejection:", reason);
});
process.on("uncaughtException", (err) => {
console.error("Uncaught exception:", err);
process.exit(1); // state is unknown — exit and let a supervisor restart
});
Treat global handlers as telemetry and graceful-shutdown hooks, not as routine error handling. An
uncaughtExceptionmeans the process is in an undefined state; log it and exit rather than trying to limp along.
Degrade gracefully
When part of an app fails, the rest should keep working. Isolate non-critical features so their failure produces a fallback, not a blank screen. Provide a sensible default, show a friendly message, and keep the core flow alive.
async function loadRecommendations(userId) {
try {
const res = await fetch(`/api/recommendations/${userId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.warn("Recommendations unavailable:", err.message);
return []; // page still renders; the widget just shows nothing
}
}
The page loads even when the recommendation service is down. Pair this with user-facing copy that’s reassuring and specific (“We couldn’t load recommendations right now”) rather than dumping a raw stack trace into the UI.
Best Practices
- Validate inputs at trust boundaries and fail fast with precise, actionable messages.
- Concentrate error handling at a few deliberate boundaries instead of wrapping every call.
- Throw for exceptional, unrecoverable failures; return result objects for expected, branchable outcomes.
- Always install
unhandledrejectionanderror/uncaughtExceptionhandlers and wire them to your monitoring. - On an uncaught exception in Node, log and exit — never attempt to continue from an unknown state.
- Degrade gracefully: isolate optional features, supply fallbacks, and show users a clear message, never a raw stack trace.