cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Exceptions & unwinding

Learn how JavaScript engines find exception handlers, unwind stack frames, run finally blocks, and carry errors across async boundaries.

By the end, you can
  • 01
    Trace a thrown exceptionExplain how V8 uses handler tables, searches frames, unwinds the stack, and runs cleanup code before a catch receives the error.
  • 02
    Predict finally outcomesTell when a finally block preserves a return, overrides a throw, runs during break or continue, and leaves a completion value alone.
  • 03
    Use 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.

Definition

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.

Real-life analogyA fire alarm leads people to an exit

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: finally runs 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 BYTECODE

A 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.

Node-only probe: a function with try, catch, and finallyJavaScript
function 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.

Stable bytecode excerptText
$ 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.
What handler table entries mean
PiecePlain meaningWhy it matters
Protected rangeA span of bytecode covered by try, catch, or finally machinery.The engine only consults handlers whose range contains the throwing instruction.
Handler offsetWhere 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 metadataA 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 THROUGH

Let'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.

Step through unwinding from validateUpi to placeOrder
Step 0 of 13Ready
Your turn: follow the blue line

Follow a throw from validateUpi as the engine searches handler tables, runs finally blocks, pops frames, and lands in placeOrder's catch.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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(" -> "));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

What unwinding does not do

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 FLOW

A 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.

Step through finally replacing or preserving completions
Step 0 of 8Ready
Your turn: follow the blue line

Replay how finally interacts with return, throw, break, and completion values. The examples are real JavaScript, not a custom evaluator.

Running in
  1. script
Next: line 27
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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 }"));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Runnables: return override, throw override, break cleanup, and eval completionPop out in the code editor (opens in a new tab)JavaScript
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.

Playground: choose the completion each block creates
Fixed completion scenarioJavaScript
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";}
Outcometry returned
step 1try: return
step 2finally: nothing
Step 2 of 4try returned
try block
catch block
finally block

The real function returned try returned. Change the finally action and notice that a finally return or throw overrides whatever try or catch was doing.

This playground runs fixed function variants. It never evaluates text you type, so the result is real JavaScript behavior without arbitrary eval.

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 FIRST

Older 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.

Node-only proof: a 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.

Where the real costs are
CaseWhat is true todayPractical reading
A try block that never throwsModern V8 can optimize functions containing try, catch, and finally.Do not remove clear error handling just to make code optimizable.
A thrown ErrorAllocates the object and usually captures stack-frame information.Throw for exceptional paths, not ordinary loop control.
Error.stackTraceLimit = 0V8/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 rethrowsRuns cleanup and then continues unwinding in outer frames.Add context only when it helps; do not hide the original cause.
Node/V8 probe: stack capture can be limitedJavaScript
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

PROMISES

A 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.

Real-life analogyA phone reminder that arrives later

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: .catch or unhandledrejection observes the later failure
In real life: Opening the reminder returns you to its task
In JavaScript: await resumes 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 functionPop out in the code editor (opens in a new tab)JavaScript
async 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");
A promise handler runs after the synchronous try has finishedPop out in the code editor (opens in a new tab)JavaScript
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);}
A timer callback needs its own handlingPop out in the code editor (opens in a new tab)JavaScript
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);}
Node-only host event for an unhandled rejectionJavaScript
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);
Which boundary can a try/catch cross?
SituationCan this try/catch catch it?What to do
Synchronous throw inside tryCaught by the nearest matching catch while the stack is still there.The engine unwinds frames immediately.
Callback scheduled with setTimeoutNot 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 awaitNot caught by surrounding synchronous try; it becomes a rejected promise and may trigger unhandledrejection.Attach .catch or return/await the promise.
await inside tryCaught when the rejection is rethrown into the suspended async function.This connects to suspended frames from the previous lesson.
Caught or uncaught by this try/catch?
  • `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"); }`
Try it yourself
0 of 6 correct

Sort each pattern by whether the surrounding try/catch can handle it, or whether a finally return converts it before a catch sees it.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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 finally for cleanup that must run on return, throw, break, or continue.
  • Await promises you expect a local try/catch to handle; otherwise attach .catch or 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.
Similar ideas that are easy to mix up
IdeaWhat it isDo not confuse it with
Language rulethrow, try, catch, finally, and promise rejection semantics.Handler tables, bytecode offsets, or TurboFan status bits.
Engine strategyHow V8 represents handlers and optimizes code that contains them.A guarantee that every engine stores exactly the same table.
Stack traceA debugging string captured on Error objects in many engines.The same thing as the live call stack or the unwinding algorithm.
Async rejectionA promise state that can be rethrown by await.A synchronous throw that an old callback-era try can catch later.

Practice exercises

6 EXERCISES
Exercise 1 · Warm-upPredict an eval completion

What does this direct eval print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
console.log(eval("try { 1 } finally { 2 }"));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upSpot the finally override

    Type the string that is printed.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    function demo() {
      try { return "try"; }
      finally { return "finally"; }
    }
    console.log(demo());

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeOrder an awaited rejection

      Which message prints first in the async await example?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      submitUpiPayment();
      console.log("after submitUpiPayment call");

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeFind the timer bug

        Where should the try/catch move if you want to handle the timer's throw locally?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        try {
          setTimeout(() => { throw new Error("late"); }, 0);
        } catch (error) {
          console.log("caught");
        }

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · PracticeName the popped frames

          Which two inner frames are popped before placeOrder catches the error?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeApply it to a real app

            Name one production rule you would apply on a checkout or payment page.

            Answer, then press Check. Spacing and letter case don’t matter.

              Quiz: check your understanding

              8 QUESTIONS

              Answer by following the completion: normal value, return, throw, break, timer callback, or awaited rejection.

              Exceptions and unwinding quiz · 8 questionsScore: first tries count
              1. Question 1 of 8What does V8's bytecode handler table do for a function containing try and catch?

                Choose an answer to see the explanation.

              2. Question 2 of 8What does this snippet print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                function demo() {
                  try {
                    return "try";
                  } finally {
                    return "finally";
                  }
                }
                console.log(demo());

                Choose an answer to see the explanation.

              3. Question 3 of 8When a throw leaves validateUpi, then chargeCard, then reaches placeOrder, what is the engine doing?

                Choose an answer to see the explanation.

              4. Question 4 of 8What does this completion-value probe print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                console.log(eval("try { 1 } finally { 2 }"));

                Choose an answer to see the explanation.

              5. Question 5 of 8Which statement about try/catch performance is accurate for modern V8?

                Choose an answer to see the explanation.

              6. Question 6 of 8What does this timer example print first?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                try {
                  setTimeout(() => console.log("timer"), 0);
                  console.log("after scheduling");
                } catch (error) {
                  console.log("caught");
                }

                Choose an answer to see the explanation.

              7. Question 7 of 8How can a promise rejection be caught by try/catch inside an async function?

                Choose an answer to see the explanation.

              8. 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 finally block 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; await brings 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.

              CompleteFrontend Clear concepts. Working examples.