I7 · Part 2 · Intermediate
Error handling & debugging
Past what the beginner errors chapter covered — error chains, lost rejections and real debugging tools.
06On this page
The first pass at errors covered
try/catch/finally, custom Error subclasses,
and reading a stack trace. This is what's past that: chaining errors
together, what happens to a rejection nobody catches, why
immutability keeps coming up in framework code, and debugging tools
past console.log.
Error chaining with cause
Catching a low-level error and throwing a more meaningful one is
normal — but doing that used to destroy the original error entirely.
The cause option keeps it attached.
function loadUser() {
try {
JSON.parse("not valid json");
} catch (dbError) {
throw new Error("failed to load user", { cause: dbError });
}
}
try {
loadUser();
} catch (e) {
console.log(e.message);
console.log(e.cause.message); // what happens?
}
"failed to load user", then the original
SyntaxError's message. Without cause, that
original error is just gone — whoever's debugging this in production
sees "failed to load user" and has to guess why. With it,
e.cause carries the full original error (and its own
stack trace) all the way up, however many layers re-throw in between.
Dry run: tracing loadUser()'s catch and cause chain
| Step | Code | What happens |
|---|---|---|
| 1 | loadUser() is called | enters the outer try block |
| 2 | JSON.parse("not valid json") | throws a SyntaxError — the string isn't valid JSON |
| 3 | inner catch (dbError) | dbError is bound to that SyntaxError |
| 4 | throw new Error("failed to load user", { cause: dbError }) | a brand-new Error is thrown, with dbError attached as its .cause — it propagates out of loadUser() |
| 5 | outer catch (e) | e is the new "failed to load user" error; e.cause is still the original SyntaxError |
| 6 | console.log(e.message) | "failed to load user" |
| 7 | console.log(e.cause.message) | the original SyntaxError's message — never lost, never printed until now |
Two separate try/catch pairs, two separate errors — cause is what lets the second error carry a live reference to the first instead of replacing it outright.
Unhandled promise rejections
A rejected promise with no .catch() anywhere in its
chain doesn't fail silently — it surfaces as a top-level
unhandledrejection event (the same mechanism
this site's own code runner listens
to, to show you an error even from code with no explicit
catch at all).
window.addEventListener("unhandledrejection", (event) => {
console.error("Unhandled:", event.reason);
event.preventDefault(); // stops it from also logging as a browser console error
});
⚠ Forgetting to return inside a .then breaks the chain's error handling
promise.then(() => { anotherAsyncCall(); }) — without a
return — lets anotherAsyncCall()'s promise
run completely detached from the outer chain. If it rejects,
no .catch() further down that outer chain will ever see
it; it becomes its own separate unhandled rejection.
window.onerror — the other global handler
window.addEventListener("error", (event) => {
console.error("Uncaught:", event.message, "at", event.filename + ":" + event.lineno);
event.preventDefault();
});
unhandledrejection above only catches promise rejections
nobody caught. This sibling event catches everything else that
escapes uncaught — a thrown error inside a plain synchronous
function, a syntax error in a dynamically loaded script, an error
thrown inside a setTimeout callback. Between the two,
nothing that goes wrong in the page is ever completely invisible —
which is exactly the mechanism
error-reporting services
build on to know a production user hit a crash at all.
⚠ It won't catch everything equally well
A script loaded from a different origin with no
crossorigin attribute reports its errors as a generic,
useless "Script error." with no file, line, or message —
a deliberate cross-origin privacy restriction, not a bug. Fix it by
adding crossorigin="anonymous" to the
<script> tag and serving the file with the matching
CORS header.
AggregateError — when there's more than one error to report
try {
await Promise.any([
fetch("/mirror-1/health"),
fetch("/mirror-2/health"),
]);
} catch (err) {
console.log(err instanceof AggregateError); // true — every candidate failed
console.log(err.errors.length); // one entry per rejected promise, in order
err.errors.forEach((e) => console.log(" -", e.message));
}
A normal catch only ever gets one error, because a
normal throw only ever carries one.
AggregateError — what Promise.any rejects
with when every candidate fails — is the built-in shape for "several
independent things went wrong at once": it's a real
Error (has .message and .stack)
that additionally carries the full list in .errors,
instead of forcing you to pick just one to report and silently drop
the rest.
Immutability — why frameworks care so much
React, Redux, and similar tools decide "did this change?" with a
single === check, not a deep comparison — because a
deep comparison of a large tree, on every single render, is far too
slow to do constantly.
const state1 = { count: 0 };
function mutateInPlace(state) {
state.count++;
return state;
}
function updateImmutably(state) {
return { ...state, count: state.count + 1 };
}
const afterMutate = mutateInPlace(state1);
console.log(state1 === afterMutate); // what happens?
const state2 = { count: 0 };
const afterUpdate = updateImmutably(state2);
console.log(state2 === afterUpdate); // what happens?
true, then false. Mutating in place changes
the same object — a === check comparing the old
reference to the new one sees no difference at all and a framework
built on that check will skip re-rendering, even though the data
genuinely changed. Building a fresh object every update
guarantees a new reference exactly when something actually changed —
which is the entire reason "don't mutate state directly" is a rule in
React, not just a style preference.
const frozen = Object.freeze({ a: 1 });
frozen.a = 2; // non-strict script: fails silently, "a" stays 1
// strict mode / modules (the normal case today): throws a TypeError
console.log(frozen.a); // 1 either way — the object never actually changed
Rule
Object.freeze is shallow —
it locks the object's own top-level properties, but a nested object
inside a frozen one is still fully mutable. It's a debugging aid for
catching accidental top-level mutation, not a deep-immutability
guarantee.
Debugging, past console.log
| Tool | For |
|---|---|
| A line-number breakpoint (Sources panel) | pause every time execution reaches that exact line |
| A conditional breakpoint | right-click the line number — pause only when an expression you type is true, e.g. user.id === 42. Essential once a bug only shows up for one specific input out of thousands. |
| A watch expression | pin any expression to re-evaluate and display at every pause, without retyping it in the console each time |
debugger; | a breakpoint written directly in the source — pauses there whenever DevTools is open, no manual click needed |
| The Network tab | every request's status, timing, headers, and actual response body — the first stop when data "never showed up" |
Once paused at any breakpoint, the call stack panel shows the exact
chain of calls that got you there — the same information a
.stack string gives you after the fact, except you can
now inspect every live variable at every level of it, not just read a
frozen snapshot of what the values were.
Interview answer, one sentence
"Wrap errors with cause so the original is never lost, remember an async failure only surfaces if something awaits or catches it, and treat window.onerror and unhandledrejection handlers as the last line that reports what slipped through, not as the main strategy."
Before you move on
- Explain what happens to the original error if you throw a new one without the
causeoption, and howe.causefixes it. - Explain why a promise rejection with no
.catch()anywhere in its chain surfaces as anunhandledrejectionevent instead of failing silently. - Name the specific bug caused by forgetting to
returninside a.then(), and why it detaches the inner promise from the outer chain's error handling. - Explain why frameworks like React decide "did this change" with a single
===check, and why mutating state in place defeats it. - Set a conditional breakpoint that only pauses for one specific input, and say why that beats a plain line breakpoint for that case.
Opens in the editor: write it, run it, and check it against real tests.
All 33 for this level →Finished reading?
0 of 41 chapters read in this series