How do errors propagate up the call stack?
By FrontendPro Editorial Team Updated 9/26/2026
Answer
When a function throws, JavaScript stops executing it immediately and looks for a try...catch in the caller. If there is none, it keeps unwinding the stack — caller after caller — until it finds one. If nothing catches it, the error reaches the top and becomes an uncaught exception.
function level3() { throw new Error("boom"); }
function level2() { level3(); } // no catch, error passes through
function level1() { level2(); }
try {
level1();
} catch (err) {
console.error(err.message); // "boom" — caught three levels up
}
Because the error passes through untouched, you only need try...catch where you can actually do something about it. Catching just to rethrow adds noise.
Async code breaks the chain
A callback runs on a later tick, when the original stack is long gone. A surrounding try...catch cannot see it:
try {
setTimeout(() => { throw new Error("lost"); }, 0);
} catch (err) {
// never runs
}
Promises solve this by carrying the error along the chain instead of the stack — a rejection skips every .then until it meets a .catch. With async/await normal try...catch works again, because await turns a rejection back into a throw:
async function load() {
try {
const res = await fetch("/api/data");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
} catch (err) {
// catches both network failures and the throw above
throw err;
}
}
Practical points
- Forgetting
await(or a missing.catch) produces an unhandled rejection that notrywill catch. - Throw
Errorobjects, not strings — only they carry a stack trace. - Add
window.onerrorandunhandledrejectionlisteners as a last-resort net for reporting.
