Exceptions & unwinding
Learn how JavaScript engines find exception handlers, unwind stack frames, run finally blocks, and carry errors across async boundaries.
- 01Follow a thrown errorExplain how V8 uses a handler table to find the right
catch, runs eachfinallyon the way, and takes calls off the stack until acatchhandles the error. - 02Predict what finally doesTell when a
finallyblock keeps a return, replaces a throw, runs onbreakorcontinue, and leaves a value alone. - 03Know what really costs timeTell apart having
try/catchin your code, which is cheap today, from throwing, recording the stack trace, and errors that happen later.
What happens after a throw
You already know try/catch. You put code inside try. If that code throws an error, the catch part runs. Earlier lessons showed how to make and handle errors: custom errors, error strategies, promise errors, and reading errors.
This lesson shows what the JavaScript engine does after a throw. The engine is the program that runs your code. Chrome and Node use an engine called V8.
A stack frame is the record of one function call that is still running. The stack frames lesson shows what a frame holds. Frames sit on the call stack. Each new call goes on top. It comes off again when the function ends.
A throw stops the current line at once. The engine then has to find code that can deal with the error. On the way, it must still run every finally block it passes.
An exception is a thrown error. Exception unwinding is what the engine does after a throw:
- It looks in the current call for a
catchorfinallyaround the line that threw. - It runs any
finallycode it must run. - It takes calls with no
catchoff the stack. - It keeps going outward until a
catchhandles the error. If none does, the browser or Node reports an "uncaught" error.
When a fire alarm rings, people go down floor by floor until they reach an exit. The exit signs were put up long before the alarm. Errors work the same way. The engine prepares its directions ahead of time. It only uses them when something throws.
- In real life: Exit signs are put up before any alarm
- In JavaScript: The engine writes down, ahead of time, where each
tryblock leads - In real life: People leave one floor, then the next
- In JavaScript: The error leaves one call, then the next, until a
catchhandles it - In real life: Each floor is closed up before people leave it
- In JavaScript:
finallyruns its cleanup as the error leaves a call - In real life: No exit is found, so someone must help
- In JavaScript: No
catchis found, so the browser or Node reports an uncaught error
This lesson comes after Suspending frames: generators & async functions, because await changes where an error comes back. Next comes Why garbage collection?, which moves from the call stack to memory.
Handler tables: where to go when code throws
V8 BYTECODEBefore V8 runs a function, it turns it into bytecode. Bytecode is a list of small, simple steps that the engine runs one by one. Each step has a number, called its offset.
A handler table is a list the engine keeps next to that bytecode. Each row says: "if a step in this range throws, jump to this step." The step it jumps to is the handler: the start of the catch or finally code.
When nothing goes wrong, the engine never reads the table. It reads it only when code leaves a try block early. A throw does that. So does a return that has to pass through a finally first.
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 Node with two options. --print-bytecode prints the bytecode. --print-bytecode-filter=handlerTableProbe limits it to this one function. The function doesn't call console.log, so its own output can't mix with V8's list.
The test checks only the parts that stay the same from run to run:
- The Node version starts with
v22. - The V8 version starts with
12.4. - The list contains the
Handler Tableheading, line 4 below.
$ node --print-bytecode --print-bytecode-filter=handlerTableProbe handler-table-probe.mjs[generated bytecode for function: handlerTableProbe]...Handler Table (size = N)# Each row: if a step in this range throws, jump to this step. Step numbers change between V8 versions.try. If they throw, the table sends the engine to the catch code on line 6, and after that to the cleanup on line 8.Each row of a handler table has three parts. The table below names them. Read each row as: the part, what it means, and why it matters.
| Part | What it means | Why it matters |
|---|---|---|
| Guarded range | A range of bytecode steps inside a try, catch, or finally. | The engine uses a row only if the step that threw is inside its range. |
| Handler offset | The number of the step to jump to when a step in the range throws. | For a catch, it is the start of the catch code. For a finally, it is the start of the cleanup, which runs before the error moves on. |
| Prediction note | A short note V8 keeps for itself, such as "this goes to a catch" or "this goes to a finally". | It is V8's own bookkeeping. Your JavaScript code can't see it. |
The exact step numbers are V8 details. Other engines store the same information in their own way. JavaScript doesn't promise a V8-style table. It only promises that catch and finally behave as the language rules say.
Unwinding: leave calls until one can catch the error
STEP THROUGHHere is a checkout with three calls, one inside the next:
placeOrderstarts the order. It has acatch.chargeCardtakes the payment. It has afinally, but nocatch.validateUpichecks the UPI ID, a payment address used in India, such asasha@bank. It has afinally, but nocatch.
The UPI ID "asha-upi" has no @, so validateUpi throws. Step through the code to watch the error move.
Follow an error from validateUpi to the catch in placeOrder. Watch each finally run and each call leave the stack.
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 "top frame" label as you step. Line 31 throws inside validateUpi. Line 35 runs that function's finally, and then its call leaves the stack. chargeCard has no catch, so line 23 runs its finally, and that call leaves too. Line 11 is the first catch the error meets. There, placeOrder turns the error into a normal string.
validateUpi, and moves down. Each call without a catch runs its finally and leaves. placeOrder has a catch, so the error stops there.- It doesn't start any function again from the top.
- It doesn't run the rest of the code in the calls it leaves.
- It runs only the
finallyandcatchcode that the language rules ask for.
How finally changes the way a block ends
CONTROL FLOWThe spec is the official rule book for JavaScript. It uses the word completion for how a statement ends. There are five kinds:
- normal: it ran to the end, sometimes with a value.
- return: it reached a
return. - throw: it threw an error.
- break: it jumped out of a loop.
- continue: it jumped to the next turn of a loop.
A finally block always runs when the code leaves its try or catch. If finally ends normally, the earlier completion goes on as before. If finally returns or throws, its new completion replaces the earlier one.
See how finally deals with a return, a throw, and a break, and when it keeps the try value. Every step replays what real JavaScript does.
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 }"));finallyReturnWins(), try wants to return "try return". Then finally returns too, and its value is the one the caller gets.The last line is a surprise. eval runs a string as code and gives back the value of the last statement it ran. eval("try { 1 } finally { 2 }") gives 1, not 2.
The finally block did work out 2. But it ended normally, so it doesn't replace anything. The spec's rule is: when finally ends normally, keep the result of the try block. That result is 1.
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 function ended with "try returned". Now change what finally does. If finally returns or throws, it replaces whatever try or catch did.
Use the playground to try each mix. Pick nothing, return, or throw for each block. You'll see three rules:
- If
tryreturns andfinallydoes nothing, thetryreturn wins. - If
finallyreturns or throws,finallywins. - If
trythrows andcatchreturns, thecatchreturn wins, unlessfinallyreplaces it.
Does try/catch make code slow today?
MEASURE FIRSTV8 makes busy functions faster by turning them into fast machine code. This is called optimizing.
Old advice said: "never put busy code inside try/catch, because the engine can't optimize it." That was true for some older versions of V8. V8's old optimizer, Crankshaft, handled try/catch badly. In 2017, V8 replaced it with two new parts: Ignition, which runs bytecode, and TurboFan, which optimizes. Today's V8 can optimize functions that contain try, catch, and finally.
try/catch/finally still gets optimizedJavaScript// Tested in Node 22 (V8 12.4). Run: node --allow-natives-syntax file.mjsfunction 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 commands that start with % are V8 test tools, not normal JavaScript. They work only when Node starts with --allow-natives-syntax:
%PrepareFunctionForOptimization(line 12) tells V8 to start collecting facts about the function.%OptimizeFunctionOnNextCall(line 15) asks V8 to optimize it on the next call.%GetOptimizationStatus(line 17) gives back a number. Each bit of that number answers one yes-or-no question, such as "is it optimized?"
The lesson test checks three printed lines in Node's V8 12.4: the result is 4, the optimized bit is true, and the TurboFan bit is true. So having try/catch/finally in the function did not stop V8 from optimizing it.
When you create an Error, V8 also records the list of calls that led to it. That list is the stack trace. It is saved as a string in error.stack. Error.stackTraceLimit sets how many calls V8 records.
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"));Line 2 prints true, because a normal error's stack has at ... lines. Line 4 sets the limit to 0. Line 6 prints only the first line, Error: payment failed, and line 7 prints false.
The table below shows where the cost really is. Read each row as: when your code has this, this is true today, so do this.
| When your code has | What is true today | What to do |
|---|---|---|
A try block that never throws | Today's V8 can optimize functions with try, catch, and finally. | Don't remove clear error handling just to make code faster. |
A thrown Error | JavaScript makes a new object and usually records the stack trace. | Throw only when something truly went wrong, not to control a normal loop. |
Error.stackTraceLimit = 0 | V8 and Node still make an Error, but its stack string has no at ... lines. | Good for a small test. It is no replacement for measuring your real app. |
A catch that throws again | It runs, and then the new error moves on to the calls further out. | Add details only when they help, and keep the first error as the cause. |
This lesson gives no timing numbers. Timings change from machine to machine and from program to program. The safe rule is smaller: throwing is the rare path, and recording the stack trace is part of its cost. Look into it only if a profile, a recording of where your program spends its time, points there. For more on the stack string, see Stack traces & the stack trace API.
Errors that happen later
PROMISESA normal try/catch covers only the call stack that is running right now. A timer callback or a promise handler runs later, after that stack is gone. So the old try can't catch their errors.
That is why the promise and async lessons use .catch, await, and host events. A host is the place where JavaScript runs: the browser or Node. Wrapping a try around the code that schedules the work is not enough.
You set a reminder, then go on with your day. When it pops up later, the thing you were doing when you set it is over. You can only deal with the reminder now. Async code works the same way.
- In real life: You read a message while using your phone
- In JavaScript: A throw happens on the stack that is running now
- In real life: A reminder pops up later
- In JavaScript: A timer or promise callback runs later, on a new stack
- In real life: You deal with the reminder when it pops up
- In JavaScript:
.catchorunhandledrejectiondeals with the later error - In real life: Opening the reminder takes you back to its task
- In JavaScript:
awaitbrings the async function back and throws the error there
await throws a rejection inside 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");Line 10 calls the function. It pauses at the await on line 3, so line 11 prints first. Then the promise rejects. The function comes back at line 3 and throws the error there, so the catch on line 5 runs.
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);}Line 2 makes a rejected promise and gives it its own .catch handler. Line 5 prints first. The handler runs later, after the try is done. The outer catch never runs.
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);}Line 2 only schedules the callback. Line 9 prints, and the outer try ends. Later, the callback runs and throws on line 4. The try/catch inside the callback handles it.
try block ends before the timer callback runs, so its catch can't help. Right: await pauses inside the try, and the error comes back there, so the catch runs.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);Line 1 listens for Node's unhandledRejection event. Line 6 makes a rejected promise that nothing handles. The try on line 5 doesn't see it, so Node fires the event instead. Line 12 keeps Node running long enough for that. Browsers fire a similar event, unhandledrejection, on window.
The table below compares four cases. Read each row as: when this happens, can the try/catch around it catch the error, and what should you do?
| What happens | Does this try/catch catch it? | What to do |
|---|---|---|
A throw inside try, right away | Yes. The nearest catch catches it while the stack is still there. | Nothing extra. The engine leaves calls until it reaches that catch. |
A throw in a setTimeout callback | No. The callback runs later, on a new stack, after the try has ended. | Put a try/catch inside the callback, or send the error to one shared handler. |
A rejected promise you don't await | No. It is not a throw. The error stays in the promise and may fire unhandledrejection. | Add .catch, or return or await the promise. |
await inside try | Yes. When the promise rejects, the await line throws inside the paused async function. | This builds on paused functions 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 and no .catch.`try { throw error; } finally { return "ok"; }``catch (e) { throw new Error("with context"); }`
Sort each piece of code. Can the try/catch around it catch the error? Or does a return in finally replace the error before any catch sees it?
Using this in real code
These details help you make everyday choices in code reviews. They are no reason to build your own error system, or to avoid clear error handling.
- Throw only when something truly went wrong: bad data, a broken rule, or a job this part of the code can't finish.
- Catch where you can do something useful: add details, try again, show a helpful message, or turn the error into a normal result.
- Use
finallyfor cleanup that must run whether the code returns, throws, breaks, or continues. - To handle a promise's error with
try/catch,awaitthe promise inside thetry. Otherwise, add.catch, or return the promise to the caller. - Measure before you change clear code for speed. If throwing shows up in a profile, throw less often on that busy path.
A real payment page might catch errors in the code that runs when you press "Pay". There it can log a custom PaymentError, show a helpful message, and use finally to hide the loading spinner. It should not use throws as the normal way to check whether every item in the cart is in stock.
Common misconceptions
- "Any try/catch stops optimization." Modern V8 can optimize code with
try/catch/finally. Throwing is the slow path to measure. - "finally always replaces the result." It always runs. But it replaces the earlier result only if it returns or throws.
- "A try around setTimeout catches errors in the callback." The callback runs later, on a new stack, after the
tryhas ended. - "error.stack is the live call stack." It is a string saved when the error was made, for debugging. Each engine writes it in its own way.
- "Handler tables are part of JavaScript." They are one way an engine makes the language rules work. Your code can't see them.
These four ideas sound alike. Read each row as: the idea, what it is, and what it is not.
| Idea | What it is | What it is not |
|---|---|---|
| A language rule | How throw, try, catch, finally, and promise rejections behave in every engine. | Handler tables, step numbers, or V8's optimization bits. |
| An engine detail | How V8 stores handlers and optimizes code that has them. | A promise that every engine stores the exact same table. |
| A stack trace | A string saved on an Error object for debugging, in many engines. | The live call stack, or the steps the engine takes after a throw. |
| A rejected promise | A promise that failed. await can throw its error inside an async function. | A throw right away that an older try around a callback could catch later. |
Practice exercises
6 EXERCISESeval runs a string as code and gives back the value of the last statement. What does this line print?
console.log(eval("try { 1 } finally { 2 }"));It prints 1. The finally block runs, but it ends normally, so the try block's result, 1, is kept.
Type the string that this code prints.
function demo() {
try { return "try"; }
finally { return "finally"; }
}
console.log(demo());It prints finally. A return inside finally replaces the try return that was waiting.
These are the last two lines of the await example in "Errors that happen later". Which message prints first?
submitUpiPayment();
console.log("after submitUpiPayment call");after submitUpiPayment call prints first. Then the async function comes back from await, and its catch prints the error message.
This outer catch never runs. Where should the try/catch go so the timer's error is handled?
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. Another way: pass the error back through a promise that the caller awaits.
In the checkout example, which two calls leave the stack before placeOrder catches the error?
validateUpi runs its finally and leaves the stack. Then chargeCard runs its finally and leaves. placeOrder catches the error.
Name one rule from this lesson that you would use on a checkout or payment page.
Good answers: throw only when something truly went wrong, measure before rewriting for speed, keep cleanup in finally, or await promises you want a try/catch to handle.
Quiz: check your understanding
8 QUESTIONSFor each question, first ask how the code ends: normally, with a return, a throw, or a break, in a timer callback, or with an awaited rejection.
Question 1 of 8In V8, what does the handler table of a function with
tryandcatchdo?Choose an answer to see the explanation.
Question 2 of 8What does this code 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 8An error leaves
validateUpi, thenchargeCard, and then reachesplaceOrder. What is the engine doing?Choose an answer to see the explanation.
Question 4 of 8What does this line print?
Read the code, then predictconsole.log(eval("try { 1 } finally { 2 }"));Choose an answer to see the explanation.
Question 5 of 8Which sentence about try/catch speed is true for today's V8?
Choose an answer to see the explanation.
Question 6 of 8What does this timer code 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
try/catchinside an async function catch a rejected promise?Choose an answer to see the explanation.
Question 8 of 8Which habit fits this lesson?
Choose an answer to see the explanation.
Key takeaways
- In V8, a function with
try/catchhas a handler table. It says where to jump when a guarded line throws. - After a throw, the engine checks the current call, runs any
finallyit must, and takes the call off the stack. It moves outward until acatchhandles the error. - A
finallyblock always runs when its code is left. A return or throw infinallyreplaces what was happening before. - Modern V8 can optimize functions with
try/catch/finally. The slow part is throwing: making the error, recording the stack trace, and recovering. - A normal
try/catchcan't catch errors in timer callbacks or in promises you don't await.awaitbrings a rejection back into the async function.
Remember the one-liner.
A throw leaves calls until it finds a catch, runs cleanup on the way out, and is meant for rare problems, not normal work.
Next, Why garbage collection? moves from the call stack to memory. You'll see how JavaScript finds objects that nothing uses any more, and frees them.