Exceptions & unwinding
Learn how JavaScript engines find exception handlers, unwind stack frames, run finally blocks, and carry errors across async boundaries.
- 01Trace a thrown exceptionExplain how V8 uses handler tables, searches frames, unwinds the stack, and runs cleanup code before a catch receives the error.
- 02Predict finally outcomesTell when a finally block preserves a return, overrides a throw, runs during break or continue, and leaves a completion value alone.
- 03Use exceptions without performance folkloreSeparate the cheap presence of try/catch in optimized code from the real cost of throwing, stack capture, and async boundaries.
What `throw` does to a running program
You already use try/catch to handle errors, and the earlier lessons on custom errors, error strategies, promise errors, and reading errors cover the language-side habits. This lesson looks underneath that syntax.
A stack frame is one running function call. The stack frames lesson explained what a frame holds. A throw is an abrupt exit from the current statement. The engine must find a frame that knows how to handle that exit, while preserving JavaScript's cleanup rules.
Exception unwinding is the engine process that starts when JavaScript throws: search handler metadata in the current frame, run any required finally code, pop frames that cannot handle the error, and continue until a catch handles it or the host reports it as uncaught.
A fire alarm sends people down floor by floor until they reach an exit. The exit signs were posted before the alarm. Exceptions are similar: the engine follows prepared handler information only when a throw needs a route out.
- In real life: Exit signs are posted before the alarm
- In JavaScript: A handler table maps protected bytecode ranges to handlers
- In real life: People leave one floor, then the next
- In JavaScript: Frames unwind outward until a catch handles the throw
- In real life: Each floor closes up before people leave
- In JavaScript:
finallyruns cleanup as control exits - In real life: No exit leaves people needing help
- In JavaScript: If no catch exists, the host reports an uncaught exception
Where the analogy stops: A fire alarm is a safety plan, not a program. Engine handler tables are implementation data that can differ while JavaScript keeps the same rules.
This lesson follows Suspending frames: generators & async functions, because await changes where an error resumes. It also points ahead to Why garbage collection?, where the focus moves from the call stack to heap reachability.
Handler tables: the engine's map from protected code to cleanup
V8 BYTECODEA handler table is engine metadata. It says, roughly, “for this range of bytecode, if control leaves abruptly, jump to this handler.” A normal run does not call the table. A throw, return through finally, or other abrupt completion can consult it.
try, catch, and finallyJavaScriptfunction handlerTableProbe(input) { try { if (input < 0) throw new Error("negative"); return input + 1; } catch (error) { return 0; } finally { input += 1; }} handlerTableProbe(1);The lesson test runs this function in a child Node process with --print-bytecode --print-bytecode-filter=handlerTableProbe. The probe does not call console.log, so V8's bytecode listing is not mixed with application output. The test asserts only stable substrings: Node starts with v22., V8 starts with 12.4., and the listing contains the heading below.
$ node --print-bytecode --print-bytecode-filter=handlerTableProbe handler-table-probe.mjs[generated bytecode for function: handlerTableProbe]...Handler Table (size = N)# V8 maps protected bytecode ranges to handler offsets; exact offsets are engine-build details.| Piece | Plain meaning | Why it matters |
|---|---|---|
| Protected range | A span of bytecode covered by try, catch, or finally machinery. | The engine only consults handlers whose range contains the throwing instruction. |
| Handler offset | Where execution should jump when a throw leaves the protected range. | For catch, this offset enters the catch code; for finally, it enters cleanup before continuing the abrupt completion. |
| Prediction metadata | A small classification such as catch, finally, or rethrow path. | This is engine bookkeeping, not JavaScript-visible state. |
The exact offsets are V8 implementation details. Other engines store equivalent metadata differently. The JavaScript guarantee is not “there is a V8-style table”; it is that catch and finally behave as the language specifies.
Stack unwinding: walk outward until someone can handle it
STEP THROUGHLet's make the stack concrete. A checkout starts in placeOrder, calls chargeCard, and then calls validateUpi. The UPI ID is invalid. The innermost function throws, two inner frames are popped, both cleanup blocks run, and placeOrder handles the error.
Follow a throw from validateUpi as the engine searches handler tables, runs finally blocks, pops frames, and lands in placeOrder's catch.
script
function enter(label) { timeline.push("enter " + label); }function leave(label) { timeline.push(label); } function placeOrder(order) { enter("placeOrder"); try { const receipt = chargeCard(order); return "confirmed: " + receipt; } catch (error) { return "handled in placeOrder: " + error.message; } finally { leave("placeOrder finally: unlock cart"); }} function chargeCard(order) { enter("chargeCard"); try { const authorization = validateUpi(order.upiId); return "charged " + authorization; } finally { leave("chargeCard finally: release terminal"); }} function validateUpi(upiId) { enter("validateUpi"); try { if (!upiId.includes("@")) { throw new Error("Invalid UPI ID"); } return "UPI authorized"; } finally { leave("validateUpi finally: close UPI session"); }} console.log(placeOrder({ upiId: "asha-upi" }));console.log(timeline.join(" -> "));Watch the frame label as you step. Line 31 throws in validateUpi. Line 35 runs that function's finally. The engine then leaves the frame. chargeCard has no catch, so line 23 runs its cleanup and that frame leaves too. Line 11 is the first matching catch, so placeOrder converts the error into a normal string.
It does not rerun functions from the top. It does not execute arbitrary code in skipped frames. It follows handler metadata and runs only the cleanup or handler code the language requires.
`finally` and completion values
CONTROL FLOWA completion is the spec's way to describe how a statement finishes: normally, with a value, with a return, with a throw, with a break, or with a continue. A finally block always runs when control leaves its try or catch. If finally returns or throws, it replaces what was already happening.
Replay how finally interacts with return, throw, break, and completion values. The examples are real JavaScript, not a custom evaluator.
script
function finallyReturnWins() { try { return "try return"; } finally { return "finally return"; }} function finallyCanSwallowThrow() { try { throw new Error("try exploded"); } finally { return "finally recovered"; }} const breakSteps = [];outer: for (const item of ["first"]) { try { breakSteps.push("try " + item); break outer; } finally { breakSteps.push("finally " + item); }} console.log(finallyCanSwallowThrow());console.log(breakSteps.join(" -> "));console.log(eval("try { 1 } finally { 2 }"));function finallyReturnWins() { try { return "try return"; } finally { return "finally return"; }} function finallyCanSwallowThrow() { try { throw new Error("try exploded"); } finally { return "finally recovered"; }} const breakSteps = [];outer: for (const item of ["first"]) { try { breakSteps.push("try " + item); break outer; } finally { breakSteps.push("finally " + item); }} console.log(finallyReturnWins());console.log(finallyCanSwallowThrow());console.log(breakSteps.join(" -> "));console.log(eval("try { 1 } finally { 2 }"));The last line is subtle: eval("try { 1 } finally { 2 }") returns 1. The finally block evaluated 2, but that expression's completion is empty for replacing the try statement's value. The spec's UpdateEmpty steps preserve the earlier value.
function scenario(tryAction, catchAction, finallyAction) { try { if (tryAction === "throw") throw new Error("try threw"); if (tryAction === "return") return "try returned"; } catch (error) { if (catchAction === "throw") throw new Error("catch threw"); if (catchAction === "return") return "catch returned"; } finally { if (finallyAction === "throw") throw new Error("finally threw"); if (finallyAction === "return") return "finally returned"; } return "normal completion";}try: returnfinally: nothingThe real function returned try returned. Change the finally action and notice that a finally return or throw overrides whatever try or catch was doing.
Use the playground to test combinations. If try returns and finally does nothing, the try return wins. If finally returns or throws, it wins. If try throws and catch returns, the catch return wins unless finally replaces it.
The cost of try/catch today
MEASURE FIRSTOlder advice said “never put hot code in try/catch because it cannot optimize.” That was true for some older V8 pipelines. V8's 2017 launch of Ignition and TurboFan specifically replaced Crankshaft, whose design struggled with structured exception handling. Modern V8 can optimize functions that contain try, catch, and finally.
try/catch/finally function can optimizeJavaScript// Node 22 / V8 12.4. Run with --allow-natives-syntax.function protectedAdd(x) { try { return x + 1; } catch (error) { return -1; } finally { x = 0; }} %PrepareFunctionForOptimization(protectedAdd);protectedAdd(1);protectedAdd(2);%OptimizeFunctionOnNextCall(protectedAdd);console.log("result", protectedAdd(3));const status = %GetOptimizationStatus(protectedAdd);console.log("optimized", Boolean(status & (1 << 4)));console.log("turbo", Boolean(status & (1 << 6)));The lesson test runs that source with --allow-natives-syntax and checks that the result is 4, the optimized bit is true, and the TurboFan bit is true in Node's V8 12.4 build. This proves the presence of try/catch/finally did not block optimization for that function.
| Case | What is true today | Practical reading |
|---|---|---|
A try block that never throws | Modern V8 can optimize functions containing try, catch, and finally. | Do not remove clear error handling just to make code optimizable. |
A thrown Error | Allocates the object and usually captures stack-frame information. | Throw for exceptional paths, not ordinary loop control. |
Error.stackTraceLimit = 0 | V8/Node still creates an Error, but the stack string has no at ... frames. | Useful for a probe; not a replacement for measuring application behavior. |
| A catch that rethrows | Runs cleanup and then continues unwinding in outer frames. | Add context only when it helps; do not hide the original cause. |
const normal = new Error("payment failed");console.log(normal.stack.includes("\n at")); Error.stackTraceLimit = 0;const withoutFrames = new Error("payment failed");console.log(withoutFrames.stack);console.log(withoutFrames.stack.includes("\n at"));Do not turn this into timing folklore. The lesson intentionally avoids timings because they vary by machine, architecture, and workload. The safe statement is narrower: throwing is the exceptional path, and stack capture is part of the cost you should measure if a profile points there. For stack formatting details, link to Stack traces & the stack trace API instead of duplicating that lesson here.
Exceptions across async boundaries
PROMISESA synchronous try/catch covers the current stack. Callback and promise code can run after that stack has gone away. That is why the promise and async lessons focus on .catch, await, and host events rather than only wrapping scheduling code in try.
A reminder that appears later is not part of what you were doing when you set it. The earlier task is over until you open the reminder. Async JavaScript has the same boundary.
- In real life: You read a reminder while using your phone
- In JavaScript: A synchronous throw happens on the current stack
- In real life: A reminder appears later
- In JavaScript: A timer or promise callback runs in a later turn
- In real life: You respond when the reminder appears
- In JavaScript:
.catchorunhandledrejectionobserves the later failure - In real life: Opening the reminder returns you to its task
- In JavaScript:
awaitresumes the async function and rethrows there
Where the analogy stops: Phone reminders are user-interface messages. JavaScript queues tasks and microtasks by precise host rules, each with its own stack.
await rethrows a rejection into the async functionasync function submitUpiPayment() { try { await Promise.reject(new Error("UPI gateway timeout")); console.log("paid"); } catch (error) { console.log("caught after await:", error.message); }} submitUpiPayment();console.log("after submitUpiPayment call");try { Promise.reject(new Error("receipt failed")).catch((error) => { console.log("promise handler:", error.message); }); console.log("try block finished");} catch (error) { console.log("caught synchronously:", error.message);}try { setTimeout(() => { try { throw new Error("timer failed"); } catch (error) { console.log("callback handled:", error.message); } }, 0); console.log("try finished before timer");} catch (error) { console.log("outer catch:", error.message);}process.on("unhandledRejection", (reason) => { console.log("unhandledRejection:", reason.message);}); try { Promise.reject(new Error("unawaited promise")); console.log("try block ended");} catch (error) { console.log("caught:", error.message);} setTimeout(() => {}, 0);| Situation | Can this try/catch catch it? | What to do |
|---|---|---|
Synchronous throw inside try | Caught by the nearest matching catch while the stack is still there. | The engine unwinds frames immediately. |
Callback scheduled with setTimeout | Not caught by the old try; the callback runs in a later turn with a new stack. | Handle inside the callback or report through a shared error boundary. |
Promise rejected without await | Not caught by surrounding synchronous try; it becomes a rejected promise and may trigger unhandledrejection. | Attach .catch or return/await the promise. |
await inside try | Caught when the rejection is rethrown into the suspended async function. | This connects to suspended frames from the previous lesson. |
`try { validateUpi(); } catch (e) { ... }`, and `validateUpi` throws before returning.`try { setTimeout(() => { throw error; }); } catch (e) { ... }``try { await Promise.reject(error); } catch (e) { ... }` inside an async function.`try { Promise.reject(error); } catch (e) { ... }` with no await or catch handler.`try { throw error; } finally { return "ok"; }``catch (e) { throw new Error("with context"); }`
Sort each pattern by whether the surrounding try/catch can handle it, or whether a finally return converts it before a catch sees it.
What a working developer should do
These internals are useful because they sharpen ordinary code review decisions. They are not a reason to build a private exception framework or avoid clear error handling.
- Throw for exceptional paths: invalid state, failed invariants, or operations the current layer cannot recover from.
- Catch at a boundary that can add context, retry, show a useful message, or convert the error into a domain result.
- Use
finallyfor cleanup that must run on return, throw, break, or continue. - Await promises you expect a local
try/catchto handle; otherwise attach.catchor return the promise. - Measure before changing clear code for performance. If throwing shows up in a profile, reduce throws on the hot path.
A production payment page might catch at the route action boundary, log a custom PaymentError, show a useful message, and keep finally for releasing a lock or hiding a spinner. It should not use thrown errors as the normal way to test whether every cart item is in stock.
Common misconceptions
- “Any try/catch blocks optimization.” Modern V8 can optimize code containing try/catch/finally; throwing is the expensive path to measure.
- “finally always overrides.” It always runs, but it only replaces the prior completion if it returns or throws.
- “A try around setTimeout catches callback errors.” The callback runs later on a new stack.
- “error.stack is the live call stack.” It is a captured debugging string with engine-specific formatting.
- “Handler tables are JavaScript syntax.” They are one engine strategy for implementing the language rules.
| Idea | What it is | Do not confuse it with |
|---|---|---|
| Language rule | throw, try, catch, finally, and promise rejection semantics. | Handler tables, bytecode offsets, or TurboFan status bits. |
| Engine strategy | How V8 represents handlers and optimizes code that contains them. | A guarantee that every engine stores exactly the same table. |
| Stack trace | A debugging string captured on Error objects in many engines. | The same thing as the live call stack or the unwinding algorithm. |
| Async rejection | A promise state that can be rethrown by await. | A synchronous throw that an old callback-era try can catch later. |
Practice exercises
6 EXERCISESWhat does this direct eval print?
console.log(eval("try { 1 } finally { 2 }"));It prints 1. The finally block runs, but its expression does not replace the try statement's completion value.
Type the string that is printed.
function demo() {
try { return "try"; }
finally { return "finally"; }
}
console.log(demo());It prints finally. A return inside finally replaces the pending try return.
Which message prints first in the async await example?
submitUpiPayment();
console.log("after submitUpiPayment call");after submitUpiPayment call prints first. Then the async function resumes and its catch prints the rejection message.
Where should the try/catch move if you want to handle the timer's throw locally?
try {
setTimeout(() => { throw new Error("late"); }, 0);
} catch (error) {
console.log("caught");
}setTimeout(() => {
try {
throw new Error("late");
} catch (error) {
console.log("caught in callback", error.message);
}
}, 0);Put the try/catch inside the callback, or return the failure through a promise that the caller awaits.
Which two inner frames are popped before placeOrder catches the error?
validateUpi is popped after its finally block, then chargeCard is popped after its finally block. placeOrder catches.
Name one production rule you would apply on a checkout or payment page.
Good answers include throwing only for exceptional paths, measuring before performance rewrites, keeping cleanup in finally, or awaiting promises you want local try/catch to handle.
Quiz: check your understanding
8 QUESTIONSAnswer by following the completion: normal value, return, throw, break, timer callback, or awaited rejection.
Question 1 of 8What does V8's bytecode handler table do for a function containing
tryandcatch?Choose an answer to see the explanation.
Question 2 of 8What does this snippet print?
Read the code, then predictfunction demo() { try { return "try"; } finally { return "finally"; } } console.log(demo());Choose an answer to see the explanation.
Question 3 of 8When a throw leaves
validateUpi, thenchargeCard, then reachesplaceOrder, what is the engine doing?Choose an answer to see the explanation.
Question 4 of 8What does this completion-value probe print?
Read the code, then predictconsole.log(eval("try { 1 } finally { 2 }"));Choose an answer to see the explanation.
Question 5 of 8Which statement about try/catch performance is accurate for modern V8?
Choose an answer to see the explanation.
Question 6 of 8What does this timer example print first?
Read the code, then predicttry { setTimeout(() => console.log("timer"), 0); console.log("after scheduling"); } catch (error) { console.log("caught"); }Choose an answer to see the explanation.
Question 7 of 8How can a promise rejection be caught by
try/catchinside an async function?Choose an answer to see the explanation.
Question 8 of 8Which habit follows from this lesson?
Choose an answer to see the explanation.
Key takeaways
- V8 bytecode for a try/catch function includes handler table metadata that maps protected ranges to handlers.
- On throw, the engine searches the current frame, runs required finally blocks, pops frames, and continues outward until a catch handles it.
- A
finallyblock always runs on exit, and a return or throw from finally overrides earlier completions. - Modern V8 can optimize functions containing try/catch/finally; the expensive path is throwing, stack capture, and recovery work.
- A synchronous try/catch does not cross timer callbacks or unawaited promises;
awaitbrings rejections back into the async frame.
Remember the one-liner.
Exceptions are stack control flow: find the next handler, clean up as you leave, and do not confuse the rare path with normal work.
Next, Why garbage collection? moves from the call stack to the heap: roots, reachability, and the trade-offs behind automatic memory management.