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.
- 01Pick a planDecide when your code should stop at once and when it should keep going with a simpler fallback.
- 02Keep errors smallUse a global handler and an error boundary so one error doesn't break the whole page.
- 03Report safelyLog useful details, hide secrets, and return result objects for failures you expect.
Plan what happens when code fails
The last two lessons, try, catch & finally and Throwing & custom errors, showed the tools. You can catch an error, throw a clearer one, keep the first error as its cause, and clean up. This lesson asks a new question: what should your program do after something goes wrong?
Good programs still have errors. The difference is what they do next. A good program:
- stops in the right place,
- keeps the user safe,
- saves enough details for a developer to fix the bug,
- and keeps a small problem from growing into a big one.
Sometimes the right move is to stop at once. Sometimes it is to show a simpler version of the page. Sometimes a function should hand back an object that says "this failed", without throwing at all.
The table below lists five plans. Read each row from left to right: the plan, when to pick it, and what your code does.
| Plan | When do you pick it? | What does your code do? |
|---|---|---|
| Fail fast | Going on with bad data would break things or hide a bug. | Throw an error, stop the current task, show a clear message, and fix the cause. |
| Recover | The user can go on safely with a fallback: a default value, a saved copy, or one feature turned off. | Catch the error where it happens, show the simpler version, and log what happened. |
| Global handler | No code caught the error. | Log it and show a general message. Don't use it as your normal way to handle errors. |
| Error boundary | One part of the page broke, but the rest still works. | Catch the error around that one part, and show a fallback just for that part. |
| Result object | The failure is expected, and the caller should choose what to do. | Return { ok: true, value } or { ok: false, error } instead of throwing. |
An error handling strategy is a plan you choose ahead of time. It answers four questions:
- Where is the error caught?
- What does the user see?
- What do developers get to read later?
- Does the program stop, or keep going with fewer features?
A promise is an object for a result that arrives later. This lesson uses promises only once, in a short paragraph about the unhandledrejection event. The Promises lessons in Stage 6 teach them in full.
Stop at once, or keep going?
INTERACTIVEFail fast means: stop as soon as your code sees data it can't trust. Throw an error and end the current task. Here are three examples where guessing would be dangerous:
- a checkout total that is not a number,
- a missing security token on a form,
- a settings file that makes no sense.
Stopping early keeps the bug small and easy to see. If the code kept going, the bad data could spread to other places first.
A smoke detector is loud on purpose. It doesn't wait and hope the smoke goes away. It sounds the alarm so people can act while the fire is still small.
- In real life: Smoke appears
- In JavaScript: Bad data appears
- In real life: The alarm sounds right away
- In JavaScript: The code throws right away
- In real life: People act while the fire is small
- In JavaScript: Developers fix the bug before bad data spreads
Recover means: keep going, but with less. The page stays honest about what it can do. Some examples:
- A missing profile photo becomes the first letter of the name.
- A saved color theme that can't be read becomes the default theme.
- A broken "recommended for you" box disappears, and the article is still readable.
The simpler thing you show instead is called a fallback.
const user = { name: "Asha", avatarUrl: "" };
const avatar = user.avatarUrl || user.name[0];
console.log(avatar);- Line 1 makes a user whose
avatarUrl(the photo address) is an empty string. - Line 2 uses
||(or). An empty string counts as false, so||picks the right side:user.name[0], the first letter,"A". - Line 3 prints
A.
A spare tire is not as good as a normal tire. But it lets you keep driving, slowly, until you can fix the real one. You still have to ask: is this spare safe enough for this road?
- In real life: A flat tire
- In JavaScript: A feature or an input fails
- In real life: A spare tire
- In JavaScript: A fallback value, saved copy, or simpler page
- In real life: Drive slowly to a repair shop
- In JavaScript: Keep the user going and record the problem
The next demo reads a config: a small piece of JSON text with an app's settings. This config has no endpoint (the web address the app sends data to). The same function can handle that in two modes:
- Strict mode fails fast: it throws an error.
- Lenient mode recovers: it uses default values and adds a warning.
Both are fine plans. Which one is right depends on the app.
endpoint, "/api", and keeps a warning.Step through the same config with no endpoint, in strict or lenient mode. Strict stops with an error. Lenient goes on with the 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 }It stopped with an error
{
"ok": false,
"error": "endpoint is required"
}Stopped: endpoint is required
- A checkout total is
NaNjust before the card is charged - A profile photo loads slowly, but the first letter of the name is available
- A security token is missing from a form when it is sent
- The saved color theme can't be read
- Before changing a database, a check finds a version number that should be impossible
- A "recommended for you" box can't load on an article page
For each case, pick the safer plan.
Global handlers: the last place to catch an error
SANDBOXEDThe best place to catch an error is close to where it happens. There, your code knows enough to fix the problem or explain it to the user.
Sometimes no code catches an error. The browser gives you one last place to hear about it: a global handler. It is a function that runs for any error that nobody caught.
You set one up with window.addEventListener("error", handler). That line means "when an error event happens on the page, call handler". An event is a message the browser sends when something happens. The handler gets an object with details, such as:
message: the error text,filename: the file the error came from,lineno: the line number.
Two things to remember:
windowexists only in the browser. A Node script doesn't have it.- A global handler only reports the error. It does not make the failed task work. Keep catching errors close to where they happen.
The browser has a second event, unhandledrejection. A promise rejects when its work fails. If no code handles that failure, the browser sends this event. Here is the smallest example: Promise.reject(new Error("No handler"));. It makes a promise that has already failed, and nothing handles it. You don't need to understand promises yet. Just remember: this event is the promise version of the error event.
A big building has an alarm in each room and one central panel at the front desk. If nobody answers a room alarm, the panel records it so staff can respond.
- In real life: A room alarm nobody answered
- In JavaScript: An error that no code caught
- In real life: The panel records which room and which alarm
- In JavaScript: The handler records the message, file, line, or reason
- In real life: Each room still needs its own fire doors
- In JavaScript: Your code still needs its own catch blocks
The lab below runs inside an iframe: a small page inside this page. The errors it makes stay inside that small page, so they can't break the lesson.
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 events yet.
The two listeners live only inside the small page (the iframe). Press a button. The iframe sends the event details back here.
Error boundaries: one broken part, not a broken page
STEP THROUGHA page is often built from small parts called widgets, like a sales box or an alerts box. To render a widget means to build what it shows on the screen.
An error boundary is a try/catch wrapped around one widget's render. If that widget throws, the boundary shows a simple fallback in its place. The rest of the page still shows up.
A fire door doesn't put out the fire. It keeps the fire in one room, so the rest of the house stays safe.
- In real life: One room catches fire
- In JavaScript: One widget throws while it renders
- In real life: The fire door closes
- In JavaScript: The boundary catches that widget’s error
- In real life: The rest of the house is safe
- In JavaScript: The other widgets still render
React, a popular tool for building pages, has its own error boundary feature. This course hasn't taught React yet, so the demo uses plain JavaScript. It has three widgets: Sales, Alerts, and Uptime. The Alerts widget throws. Each widget gets its own boundary.
In React, an error boundary only catches errors that happen while the page is being built. It does not catch errors in:
- click handlers and other event handlers,
- timers, such as
setTimeout, - code that runs later, after the page is built.
Catch those errors in their own code.
catch block returns a fallback card. Sales and Uptime still render.Step through a plain JavaScript boundary around three widgets. One throws, but the other two still render.
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>"; }} 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. The boundary catches the error and returns one fallback card. Sales and Uptime still render.
Logging & monitoring: save the details safely
PRACTICALA log is a saved record of what happened in your app. If you catch an error but nobody can tell what went wrong, the job is only half done. A good error log answers these questions:
- What failed? (the
message) - Where in the code? (the
stack, the list of function calls that led to the error) - On which page, and after which click or action?
- In which release, meaning which version of the app?
Logs must also be safe. Never put these in a log:
- passwords,
- tokens (secret codes that prove who a user is),
- other secrets, card numbers, or private details about a person.
To redact a value means to replace it with a marker like [redacted] before saving it.
Some errors happen thousands of times. You can sample them: save only some copies, enough to understand the problem.
A monitoring service, such as Sentry, collects errors from many users. It groups the same error together, alerts your team, and shows which release each error came from. Your code still decides what is safe to send.
The user needs something different from the log: a short, clear next step. For a failed save, show We couldn't save your changes. Try again. Keep the message, stack, page, and release in the log. Never leave a catch block empty. If you can't recover, log the error or throw it again, so the problem doesn't disappear.
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",
};
}
function userMessageFor(error) {
return error.message ? "We couldn't save your changes. Try again." : "Something went wrong. Try again.";
}
const log = logError(new Error("database detail"), { authToken: "secret", route: "/settings" });
console.log(log.context.authToken, userMessageFor(new Error("database detail")));- Lines 1–7,
redactSecrets: line 2 copies the context with{ ...context }, so the original object stays the same. - Line 3 loops over every key in the copy. Line 4 checks each key name with
/token|password|secret/i. It matches any key that contains "token", "password", or "secret". Theimeans capital letters count too, soauthTokenmatches. A match is replaced with"[redacted]". - Lines 9–16,
logError: it builds the log with the message, the stack, the safe context, and the release. - Lines 18–20,
userMessageFor: it picks the short text the user sees. - Line 23 prints
[redacted] We couldn't save your changes. Try again.
redactSecrets hides the token before the log keeps it. The route stays, because it helps you find the bug.A monitoring service can group the same error and alert your team. But it doesn't know your privacy rules. It also can't tell if a fallback is honest. Your code makes those choices.
Result objects: return the failure instead of throwing it
STEP THROUGHThrowing is a good fit for surprises and bugs. But some failures are normal and expected:
- Text a user typed may not be valid JSON.
- A search may find nothing.
- A save may fail twice before it works.
For these, a function can return a result object instead of throwing. It always has an ok property:
{ ok: true, value }means it worked.valueis the answer.{ ok: false, error }means it failed.errorsays why.
The code that called the function checks ok first, then uses value or error.
Instead of throwing an error at you, the function hands you a sealed envelope. The label on the outside says what is inside: an answer, or an error.
- In real life: The label on the envelope says success
- In JavaScript:
{ ok: true, value } - In real life: The label on the envelope says failure
- In JavaScript:
{ ok: false, error } - In real life: You read the label before you open it
- In JavaScript: The caller checks
result.okbefore it usesresult.value
result object, and ok says which kind it is.Follow a result object from reading JSON to the safe success path.
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 result says ok: true, so the caller can read value.
Result objects also work well with small helper functions. A retry helper calls a task again when it fails. It stops at the first result with ok: true. No throwing is needed to end the loop.
Only retry temporary failures, such as a network that is down for a moment. Don't retry a failure that will happen every time, such as a wrong password. The code below marks a temporary error with transient: true ("transient" means short-lived).
A good retry also waits longer before each new try: 100 ms, then 200 ms, then 400 ms. This is called exponential backoff. Always stop after a small number of tries.
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;
if (!result.error.transient) return result;
if (attempt < attempts) {
const delayMs = 100 * 2 ** (attempt - 1);
console.log("retry after", delayMs, "ms");
}
}
return last;
}
let tries = 0;
function operation() {
tries += 1;
return tries < 3
? { ok: false, error: Object.assign(new Error("offline"), { transient: true }) }
: { ok: true, value: "saved" };
}
const result = retry(operation);
console.log(result.value, tries);- Line 4 runs the task once and keeps its result object.
- Line 5: if
okis true, return the result. We're done. - Line 6 remembers the last failure, in case every try fails.
- Line 7: if the error is not temporary, stop now. Trying again won't help.
- Lines 8–11: if there are tries left, work out the wait.
2 ** (attempt - 1)means 2 to the power ofattempt - 1, so the waits are 100, then 200. This demo only prints the wait; it doesn't really pause. - Lines 16–22,
operation: it fails twice with a temporary "offline" error, then returns{ ok: true, value: "saved" }.Object.assignaddstransient: trueto the error. - Line 24 starts the retry. Line 10 prints
retry after 100 ms, thenretry after 200 ms. The third try works, so line 25 printssaved 3.
Where you’ll use this
You'll pick an error plan wherever your code works with users, data, or other servers. The rule: catch the error at the closest place that can make a good choice. Errors you didn't expect can go up to a boundary or a global handler, which reports them.
Read each row as: in this part of an app, this plan usually fits, and here is what it looks like.
| Where in the app? | Which plan usually fits? | What does it look like? |
|---|---|---|
| A form | Recover where the error happens | Show a message next to each wrong field. Don't send the bad data. |
| Working out a payment | Fail fast | Stop if the total is NaN or the currency is missing. |
| A dashboard widget | Error boundary | Show one fallback card. The other widgets stay on screen. |
| JSON a user typed | Result object | Return { ok: false, error } and let the caller pick a message. |
| An error nobody caught in the browser | Global handler | Save the message, stack, action, and release, with secrets hidden. |
To practice reading error messages and the stack, see the Reading errors and Debugging lessons. A closure can help you build a logger that remembers the release; see the Closures lesson.
Common misconceptions
- “If I catch it, it’s fixed.” Not quite. A
catchblock must do one of three things: recover honestly, add details and throw again, or log the error safely. - “A global handler means I don’t need my own catch blocks.” Not quite. A global handler runs last. By then it is usually too late to recover in a specific way.
- “Always recover if you can.” Not quite. Recovering with unsafe data is worse than stopping with a clear error.
- “Error boundaries are only a React thing.” Not quite. React has a feature with that name, but the idea works anywhere: keep one broken part of the page from breaking the rest.
- “Log everything.” Not quite. Log enough to find the bug. Hide secrets, and save only some copies of an error that repeats a lot.
- “Result objects are always better than throwing.” Not quite. Use them for failures you expect. Throw for bugs and for data that should never happen.
Practice exercises
5 EXERCISESRead the code without running it. The ? : on the last line picks result.value when result.ok is true, and "bad" when it is false. What does it print?
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");The text is valid JSON, so parseCount returns { ok: true, value: 3 }. result.ok is true, so the ? : picks result.value and prints 3. Invalid JSON would return { ok: false, error } and print bad.
A checkout is about to charge a card, but the total is NaN ("not a number"). 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 total must fail fast. If the code guessed a total, it could charge the user the wrong amount. Number.isNaN(total) is true, so it prints fail fast.
Finish the code so one widget that throws can't stop the other widgets.
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);
}
});Each widget gets its own try/catch, so map keeps going after one fails. One broken widget becomes one fallback card.
What does this code 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 key authToken contains "Token", and the i lets capital letters match. So its value becomes [redacted]. The code prints [redacted], not the secret.
A promise fails, and no code handles the failure. Which browser event fires?
window.addEventListener("unhandledrejection", (event) => {
console.log(event.reason);
});unhandledrejection is the browser's last place to hear about a failed promise that no code handled. event.reason is why it failed. Use it to report the problem, not as your normal way to handle promise errors.
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 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 is a browser
errororunhandledrejectionlistener for?Choose an answer to see the explanation.
Question 4 of 7In this lesson's plain JavaScript boundary, what happens when one widget throws?
Choose an answer to see the explanation.
Question 5 of 7What does the redact 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 going on would spread bad data, hide a bug, or be unsafe.
- Recover when there is an honest fallback, such as a default or a simpler page.
- Global
errorandunhandledrejectionhandlers report errors nobody caught. They don't replace your own catch blocks. - An error boundary keeps one broken part of the page from breaking the rest.
- A good log has the message, stack, page, user action, and release. Hide secrets first.
- A result object says plainly whether a call worked:
{ ok: true, value }or{ ok: false, error }.
An error handling strategy means: catch each error at the closest place that can make a safe choice, and report everything else clearly.
Up next: Synchronous vs asynchronous.