Error handling strategies
Decide when JavaScript should fail fast, recover, report uncaught errors, isolate broken UI, log safely, and return result objects instead of surprise exceptions.
- 01Choose a strategyDecide when to fail fast and when to recover with reduced behavior.
- 02Contain failuresUse global handlers and render boundaries without breaking the page.
- 03Report safelyLog useful context, redact secrets, and model expected failures with result objects.
Strategy before syntax
The previous lessons, try, catch & finally and Throwing & custom errors, taught the mechanics: catch a failure, throw a better one, attach a cause, and clean up. This lesson answers the next question: what should happen after something goes wrong?
Professional JavaScript is not error-free JavaScript. It is code that fails in the right place, keeps users safe, reports enough information to fix the bug, and avoids turning a small problem into a bigger one. Sometimes that means stopping immediately. Sometimes it means showing a fallback. Sometimes it means returning an object that says failure without throwing at all.
| Strategy | Use it when | What to do |
|---|---|---|
| Fail fast | Invalid state would corrupt data or hide a programming mistake. | Throw, stop the current operation, show a clear failure, and fix the cause. |
| Recover | The user can continue safely with a fallback, default, cached value, or disabled feature. | Catch locally, use a reduced mode, and tell logs what happened. |
| Global handler | No local catch handled the problem. | Record the final alarm and show a generic message. Do not use it for normal control flow. |
| Boundary | One UI region failed while the rest of the page can remain useful. | Catch around that render/task and show a fallback for only that region. |
| Result object | Failure is expected and callers should choose what to do next. | Return { ok: true, value } or { ok: false, error } instead of throwing. |
An error handling strategy is a deliberate choice about where a failure is caught, what the user sees, what developers learn, and whether the program stops or continues in a reduced mode.
We will keep promises small here: unhandledrejection gets one practical paragraph and a tiny Promise.reject demo. The full promise model comes later in Stage 6, in the Promises and Error handling with promises lessons.
Fail fast vs recover
INTERACTIVEFail fast means stop as soon as code detects an unsafe state. It is not pessimism; it is containment. A bad checkout total, a missing security token, or an impossible configuration should not be guessed around. Throw, stop the current operation, and make the bug visible while it is still small.
A smoke detector is annoying on purpose. It does not quietly open a window and hope. It screams so humans can act before the kitchen is gone. Failing fast does the same for impossible program states.
- In real life: Smoke appears
- In JavaScript: Invalid state appears
- In real life: The alarm screams immediately
- In JavaScript: Code throws immediately
- In real life: People investigate while the fire is small
- In JavaScript: Developers fix the bug before data is corrupted
Where the analogy stops: A smoke detector only reports. Code may also clean up resources or wrap an error with more context before it rethrows.
Recovering means the program can continue honestly in a reduced mode. A missing avatar can become initials. A saved theme preference can fall back to the default theme. A recommendations panel can disappear while the article remains readable.
Recovery is not pretending nothing happened. It is a safe, reduced path that keeps the user moving. The trick is knowing whether the spare tire is safe enough for this road.
- In real life: A flat tire
- In JavaScript: A feature or input fails
- In real life: A spare tire
- In JavaScript: A fallback value, cache, or simpler UI
- In real life: Drive slowly to a repair shop
- In JavaScript: Keep users moving while logging the problem
Where the analogy stops: A spare tire is temporary. Recovery should not hide the original problem forever; it should be logged and repaired.
The playground below parses a small configuration. Strict mode throws when required data is missing. Lenient mode uses defaults and returns a warning. Both are valid strategies in different products.
Step through the same bad config in strict or lenient mode. Strict fails fast; lenient recovers with defaults and a warning.
script
function parseConfig(text, mode) { try { const draft = JSON.parse(text); if (typeof draft.endpoint !== "string") throw new Error("endpoint is required"); if (!Number.isInteger(draft.retries) || draft.retries < 0) throw new Error("retries must be a non-negative integer"); return { ok: true, config: { ...defaults, ...draft }, warnings: [] }; } catch (error) { if (mode === "strict") throw error; return { ok: true, config: defaults, warnings: [error.message] }; }} parseConfig("{ \"retries\": 2, \"cache\": true }", "strict");const defaults = { endpoint: "/api", retries: 3, cache: false }; function parseConfig(text, mode) { try { const draft = JSON.parse(text); if (typeof draft.endpoint !== "string") throw new Error("endpoint is required"); if (!Number.isInteger(draft.retries) || draft.retries < 0) throw new Error("retries must be a non-negative integer"); return { ok: true, config: { ...defaults, ...draft }, warnings: [] }; } catch (error) { if (mode === "strict") throw error; return { ok: true, config: defaults, warnings: [error.message] }; }}{ "retries": 2, "cache": true }Stopped result
{
"ok": false,
"error": "endpoint is required"
}Stopped: endpoint is required
- A checkout total is
NaNright before charging a card - A profile photo CDN is slow, but initials are available
- A required security token is missing from a form submission
- Reading a saved theme setting fails
- A database migration precheck sees an impossible schema version
- A recommendation widget cannot load on an article page
Sort each scenario by the safer strategy.
Global handlers: error & unhandledrejection
SANDBOXEDLocal code should catch errors where it has enough context to recover or explain the problem. A browser global handler is the last safety net after that local handling did not happen. In most browsers, window.addEventListener("error", handler) receives an uncaught error event with fields such as message, filename, and lineno.
A second browser event, unhandledrejection, reports a Promise that rejects and is not handled. Minimal example: Promise.reject(new Error("No handler"));. Do not worry if promises still feel mysterious; they are taught in Stage 6. For now, remember that this event is the promise-shaped version of the central alarm panel.
A building has local alarms and one central panel. If nobody handles a room alarm, the panel records it so people can respond. Browser global handlers fill that final reporting role.
- In real life: A room alarm nobody answered
- In JavaScript: An error nobody caught locally
- In real life: The panel logs room and alarm type
- In JavaScript: The handler logs message, file, line, or rejection reason
- In real life: Building staff still need fire doors and procedures
- In JavaScript: Apps still need local catch blocks and boundaries
Where the analogy stops: The alarm panel is too late to save work inside the room. Global handlers are best for reporting, not normal recovery.
window.addEventListener("error", (event) => { parent.postMessage({ kind: "error", message: event.message, filename: event.filename, lineno: event.lineno, }, "*");}); window.addEventListener("unhandledrejection", (event) => { parent.postMessage({ kind: "unhandledrejection", reason: String(event.reason && event.reason.message || event.reason), }, "*");});No alarms yet.
The listeners are installed only inside the sandboxed iframe. Trigger an error or rejection to see the event details posted back.
Error boundaries
STEP THROUGHAn error boundary is a protective wrapper around a region of UI. In a framework, you can think of it as a component-level try/catch for rendering: if one child fails, the boundary shows a fallback for that area instead of letting the whole screen disappear.
A fire door does not make fire good. It keeps one burning room from taking down the whole house. Boundaries apply that idea to UI.
- In real life: One room catches fire
- In JavaScript: One widget throws while rendering
- In real life: The fire door closes
- In JavaScript: The boundary catches that widget’s error
- In real life: The rest of the house stays usable
- In JavaScript: Neighboring widgets still render
Where the analogy stops: A fire door does not repair the room. A boundary still needs logging and a fallback that tells the truth.
React has a formal error-boundary feature, but this course has not taught React yet. So the demo uses plain JavaScript: three widget render functions, one of which throws. The boundary wraps each widget separately.
Step through a plain-JS boundary around three widget render functions. One throws, but the others survive.
script
function renderWidget(widget) { return "<article>" + widget.title + ": " + widget.render() + "</article>";} function renderWithBoundary(widget) { try { return renderWidget(widget); } catch (error) { return "<article>" + widget.title + ": temporarily unavailable</article>"; } widgets.map(renderWithBoundary);function renderWidget(widget) { return "<article>" + widget.title + ": " + widget.render() + "</article>";} function renderWithBoundary(widget) { try { return renderWidget(widget); } catch (error) { return "<article>" + widget.title + ": temporarily unavailable</article>"; }} widgets.map(renderWithBoundary);The Alerts widget throws, but the boundary catches it and returns one fallback card. Sales and Uptime still render.
Logging & monitoring
PRACTICALA handled error that nobody can diagnose is only half handled. Good logging answers: what failed, where, during which user action, in which release, and with what safe context? Useful fields include message, stack, route or screen, user action, feature flag, browser information when available, and release version.
Logs must also be safe. Do not send passwords, tokens, secrets, credit card data, or full personal data just because it was nearby. Redact first, sample repeated noise when volume is high, and use monitoring services such as Sentry or similar tools neutrally: they collect, group, alert, and connect events to releases; your code still decides what is appropriate to send.
function redactSecrets(context) {
const clone = { ...context };
for (const key of Object.keys(clone)) {
if (/token|password|secret/i.test(key)) clone[key] = "[redacted]";
}
return clone;
}
function logError(error, context) {
return {
message: error.message,
stack: error.stack,
context: redactSecrets(context),
release: "2026.09.25",
};
}A monitoring service can group repeated stack traces and alert a team, but it cannot know your privacy policy or whether a fallback is truthful. Keep strategy decisions in your code.
Result objects
STEP THROUGHThrowing is excellent for surprising failures and invalid states. But some failures are expected: user text may not be valid JSON, a search may find no match, a retryable operation may fail twice before succeeding. In those cases, a result object can make the success and failure paths explicit.
Instead of a surprise exception flying across the room, the function hands back a sealed envelope. The outside says whether it contains a value or an error.
- In real life: The envelope label says success
- In JavaScript:
{ ok: true, value } - In real life: The envelope label says failure
- In JavaScript:
{ ok: false, error } - In real life: You read the label before reaching inside
- In JavaScript: Callers check
result.okbefore usingresult.value
Where the analogy stops: An envelope is passive. Code must still decide whether to retry, show a message, log, or rethrow.
Follow a result object from a parse attempt to a safe success branch.
script
function tryParse(text) { try { return { ok: true, value: JSON.parse(text) }; } catch (error) { return { ok: false, error }; }} if (result.ok) { console.log(result.value.name);}function tryParse(text) { try { return { ok: true, value: JSON.parse(text) }; } catch (error) { return { ok: false, error }; }} const result = tryParse('{ "name": "Ada" }');if (result.ok) { console.log(result.value.name);}{
"ok": true,
"value": {
"name": "Ada"
}
}The envelope says ok: true, so callers may read value.
tryParse: expected parse failures become explicit data.Result objects combine well with small helpers. A retry helper can call an operation several times and stop at the first ok: true result without using exceptions as loop control.
function retry(operation, attempts = 3) {
let last;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const result = operation();
if (result.ok) return result;
last = result;
}
return last;
}Where you’ll use this
Error strategy shows up anywhere JavaScript talks to users, data, or other systems. The pattern is to catch as close as possible to the place that can make a good decision, then let truly unexpected failures rise to a boundary or global handler for reporting.
| Place | Likely strategy | Example |
|---|---|---|
| Form validation | Recover locally | Show field messages; do not submit invalid data. |
| Payment calculation | Fail fast | Stop if total is NaN or currency is missing. |
| Dashboard widget | Boundary | Show one fallback card and keep neighboring widgets visible. |
| User-provided JSON | Result object | Return { ok: false, error } and let the caller choose a message. |
| Uncaught browser failure | Global handler | Record message, stack, action, and release safely. |
If you want to revisit reading stack traces, the published Reading errors and Debugging lessons are good companions. Closures can also help when you create a logger that remembers release information; see the Closures lesson.
Common misconceptions
- “Catching means fixed.” A catch block must recover honestly, add context and rethrow, or report safely.
- “Global handlers can replace local handling.” They are final alarms, usually too late for specific recovery.
- “Recover whenever possible.” Unsafe recovery is worse than a loud failure.
- “Error boundaries are only a React trick.” React has an API, but the idea is broader: isolate one UI region.
- “Log everything.” Log enough to debug, but redact secrets and sample noisy repeats.
- “Result objects are always better than throws.” Use them for expected failures; throw for impossible states and bugs.
Practice exercises
5 EXERCISESRun the starter code mentally. What does it print for the valid JSON input?
function parseCount(text) {
try {
return { ok: true, value: JSON.parse(text).count };
} catch (error) {
return { ok: false, error };
}
}
const result = parseCount('{ "count": 3 }');
console.log(result.ok ? result.value : "bad");function parseCount(text) {
try {
return { ok: true, value: JSON.parse(text).count };
} catch (error) {
return { ok: false, error };
}
}
const result = parseCount('{ "count": 3 }');
console.log(result.ok ? result.value : "bad");parseCount returns a success envelope for valid JSON, so the ternary reads value and prints 3. Invalid JSON would return { ok: false, error }.
A checkout is about to charge a card, but the total is NaN. Should it fail fast or recover?
const total = Number.NaN;
console.log(Number.isNaN(total) ? "fail fast" : "recover");const total = Number.NaN;
console.log(Number.isNaN(total) ? "fail fast" : "recover");A NaN payment total must fail fast. Guessing a total could charge the user incorrectly.
Complete the dashboard pattern so one throwing widget cannot stop its neighbors.
const widgets = [salesWidget, alertsWidget, uptimeWidget];
const cards = widgets.map((widget) => {
// Wrap one widget render here.
});const cards = widgets.map((widget) => {
try {
return renderWidget(widget);
} catch (error) {
return renderFallback(widget, error);
}
});The map keeps moving because each widget gets its own boundary. One failed render becomes one fallback card.
What should the safe logger print for authToken?
function redactSecrets(context) {
const clone = { ...context };
for (const key of Object.keys(clone)) {
if (/token|password|secret/i.test(key)) clone[key] = "[redacted]";
}
return clone;
}
console.log(redactSecrets({ userId: 7, authToken: "abc", password: "p" }).authToken);function redactSecrets(context) {
const clone = { ...context };
for (const key of Object.keys(clone)) {
if (/token|password|secret/i.test(key)) clone[key] = "[redacted]";
}
return clone;
}
console.log(redactSecrets({ userId: 7, authToken: "abc", password: "p" }).authToken);The logger replaces authToken with [redacted] before returning the context. It prints [redacted], not the secret value.
Name the event that catches a rejected promise when no code handles that rejection.
window.addEventListener("unhandledrejection", (event) => {
console.log(event.reason);
});unhandledrejection is the browser’s global safety net for a Promise rejection with no rejection handler. Use it for reporting, not normal promise control flow.
Check your understanding
7 QUESTIONSQuestion 1 of 7When should code fail fast?
Choose an answer to see the explanation.
Question 2 of 7What does the tryParse result code print?
Read the code, then predictfunction tryParse(text) { try { return { ok: true, value: JSON.parse(text) }; } catch (error) { return { ok: false, error }; } } const result = tryParse("not json"); console.log(result.ok ? "value" : "failure");Choose an answer to see the explanation.
Question 3 of 7What should a browser
errororunhandledrejectionlistener be used for?Choose an answer to see the explanation.
Question 4 of 7In this lesson’s plain-JS boundary, what happens when one widget throws?
Choose an answer to see the explanation.
Question 5 of 7What does the redaction code print?
Read the code, then predictfunction redact(context) { const copy = { ...context }; if ("apiToken" in copy) copy.apiToken = "[redacted]"; return copy; } console.log(redact({ apiToken: "secret", action: "save" }).apiToken);Choose an answer to see the explanation.
Question 6 of 7Why use a result object?
Choose an answer to see the explanation.
Question 7 of 7What does the retry loop code print?
Read the code, then predictlet tries = 0; function operation() { tries += 1; return tries < 2 ? { ok: false, error: new Error("wait") } : { ok: true, value: "done" }; } let result; for (let i = 0; i < 3; i += 1) { result = operation(); if (result.ok) break; } console.log(result.value);Choose an answer to see the explanation.
Key takeaways
- Fail fast when continuing would corrupt data, hide a bug, or be unsafe.
- Recover when there is a truthful fallback or reduced mode.
- Global
errorandunhandledrejectionhandlers are final reporting alarms, not a replacement for local strategy. - Error boundaries isolate one UI region so the rest can keep working.
- Logs should include message, stack, context, user action, and release, with secrets redacted.
- Result objects make expected success and failure explicit:
{ ok: true, value }or{ ok: false, error }.
Error handling strategy means catching failures at the narrowest place that can make a safe decision, and reporting everything else clearly.
Up next: Synchronous vs asynchronous.