Error handling — try/catch, custom errors, and async propagation
An uncaught error doesn't just stop the function it happened in — it propagates up the call stack until something catches it or it reaches the top and crashes the program, and understanding that propagation path is what makes try/catch placement a real design decision.
3 min read
An error propagates up the call stack until something catches it
function level3() { throw new Error("something broke"); }
function level2() { level3(); } // no try/catch here — the error just keeps going
function level1() {
try {
level2();
} catch (err) {
console.log("caught:", err.message); // caught HERE, two levels up from where it was thrown
}
}
level1();Throwing an error doesn't require the immediately-calling function to handle it — the error propagates up through every function call on the stack (unwinding each one, since none of them return normally) until it finds a try/catch somewhere in that chain, or reaches the very top of the program with nothing catching it, which crashes a Node process or logs an uncaught error in a browser. This propagation is genuinely useful: a low-level function doesn't need to know or care how its caller wants to handle a failure — it just throws, and whichever level actually has the context to respond meaningfully catches it.
Custom error classes: real subclasses of Error, distinguishable by type
class ValidationError extends Error {
constructor(message, field) {
super(message); // Error's own constructor sets message, stack trace, etc.
this.name = "ValidationError";
this.field = field; // custom errors can carry EXTRA structured data
}
}
try {
throw new ValidationError("Email is required", "email");
} catch (err) {
if (err instanceof ValidationError) {
console.log(`Validation failed on ${err.field}: ${err.message}`);
} else {
throw err; // NOT a ValidationError — re-throw, let it propagate further up
}
}Extending Error (using the same class/super() mechanism from the previous lesson) creates a genuine, distinguishable error type — instanceof ValidationError reliably tells one kind of error from another, and the custom class can carry additional structured data (field, an HTTP status code, an error code) beyond a generic Error's plain message string. Catching broadly and checking instanceof (rather than assuming every caught error is the one kind expected) is the pattern that lets a single catch block handle known error types specifically while explicitly re-throwing anything unexpected, rather than silently swallowing errors it wasn't actually prepared to handle.
finally: runs regardless of whether an error was thrown or caught
function loadData() {
showSpinner();
try {
return fetchData(); // might throw
} finally {
hideSpinner(); // runs whether fetchData() succeeded, threw, or even if a `return` happened above
}
}Code in a finally block runs unconditionally after the try (and any catch) completes — whether the try block succeeded, threw an error, or even executed a return statement — making it the right place for cleanup that must happen regardless of outcome (hiding a loading spinner, releasing a lock, closing a connection). This guarantee (cleanup always runs) is finally's entire reason to exist; the same cleanup written after the try/catch block instead would simply never run if an uncaught error occurred or a return fired inside the try.
Async error propagation: an unhandled rejection is a real, separate failure mode
async function riskyOperation() {
throw new Error("failed"); // becomes a REJECTED promise, not a synchronous throw the caller can try/catch normally
}
riskyOperation(); // called WITHOUT await or .catch() — the rejection has nowhere to go
// Result: an "unhandled promise rejection" — a real, separate failure mode from a synchronous uncaught errorBecause an async function's thrown error becomes a rejected Promise (covered in the async/await lesson) rather than a synchronous exception, calling it without awaiting it inside a try/catch, or attaching a .catch(), means the rejection has nowhere to propagate to — both Node.js and browsers treat this as a distinct, real failure mode ("unhandled promise rejection"), separate from a normal uncaught synchronous error, and it's a genuinely common real bug: forgetting to await (or otherwise handle) an async call whose failure actually matters.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. If a function three levels deep throws an error with no try/catch in the two levels between it and the caller, what happens?
2. What does extending the built-in `Error` class actually enable that a plain thrown object doesn't?
3. Why does calling an async function without awaiting or .catch()-ing it create a distinct failure mode from a normal uncaught error?