Saw an interesting question pop up in a Discord server the other day that reminded me of a classic JavaScript head-scratcher: what really happens when you use flow control statements like return or throw inside a finally block? Most of us use try...catch...finally regularly, especially finally for crucial cleanup tasks: closing file handles, releasing network connections, resetting state, you name it. Its guarantee to run, whether the try block succeeds, fails (throw), or exits early (return), is fundamental.

But things get weird when finally itself tries to dictate the flow.

The Purpose of finally

The primary job of the finally block is cleanup. Consider this typical scenario:

function processData() {
  let resource = acquireResource(); // Might throw
  try {
    // Do work with the resource
    let result = resource.doSomethingCritical(); // Might throw
    if (result.needsEarlyExit) {
      console.log("Exiting early based on result.");
      // Cleanup still needs to happen!
      return { status: 'partial', data: result.partialData };
    }
    console.log("Processing completed normally.");
    return { status: 'ok', data: result.fullData }; // Normal exit
  } catch (error) {
    console.error("An error occurred during processing:", error);
    // Maybe re-throw, maybe return an error status
    // Cleanup still needs to happen!
    throw new Error("Failed to process data", { cause: error });
  } finally {
    console.log("Cleaning up the resource.");
    resource.release(); // <-- The essential cleanup step
  }
}

// Example usage
try {
  const outcome = processData();
  console.log("Outcome:", outcome);
} catch (e) {
  console.error("Caught top-level error:", e.message);
}

No matter how the try block finishes: normal completion, return, or throw (caught by catch or propagating outwards), the finally block executes resource.release(). Predictable, reliable. That’s what we want.

The Completion Record

So, why does adding flow control inside finally cause trouble? The answer lies in an internal mechanism defined by the ECMAScript specification: the Completion Record. You don’t interact with it directly in code, but understanding the concept clarifies a lot of JavaScript’s execution behavior.

Think of a Completion Record as a small, internal “status report” generated whenever a block of code finishes executing. It essentially contains:

  1. Type: How did it finish? (normal, return, throw, break, continue)
  2. Value: If it was a return or throw, what value was returned or thrown? (Undefined otherwise for normal).
  3. Target: (Relevant for break/continue in loops/labels, less critical here).

When a try block executes, it produces a Completion Record. If an error occurs and there’s a catch, the catch block executes and it produces a Completion Record.

Now, here comes the finally block. It always runs after try (and catch, if triggered). The crucial part is this:

  • finally executes.
  • It produces its own Completion Record.
  • If the finally block’s Completion Record type is normal, then the original Completion Record from the try (or catch) block is the one that determines what happens next (e.g., the function returns the value from try, or the error from catch continues propagating).
  • However, if the finally block’s Completion Record type is not normal (i.e., it contains a return, throw, break, or continue), then this new Completion Record from finally overrides the original one.

The Gotchas in Practice

Let’s see what that overriding behavior looks like.

Gotcha 1: return inside finally swallows everything.

function testReturnInFinally() {
  try {
    console.log("Try block starts.");
    //return "Value from try"; // This return gets ignored!
    throw new Error("Error from try"); // This throw gets ignored too!
  } catch (e) {
    console.error("Caught error:", e.message);
    return "Value from catch"; // Even this return gets ignored!
  } finally {
    console.log("Finally block starts.");
    return "Value from finally"; // <-- This takes precedence!
  }
  // This line is unreachable
  console.log("After try...catch...finally");
}

console.log("Result:", testReturnInFinally());
// Output:
// Try block starts.
// Caught error: Error from try
// Finally block starts.
// Result: Value from finally

Notice how the return in finally completely replaced the intended throw from try (which was caught) and even the return from catch. The function simply returned "Value from finally". If the try block had completed normally with a return, that too would have been overridden. This can silently mask errors or lead to completely unexpected return values.

Gotcha 2: throw inside finally masks original errors/returns.

function testThrowInFinally() {
  try {
    console.log("Try block starts.");
    return "Value from try"; // This return gets ignored!
    // throw new Error("Original error from try"); // This original error also gets masked
  } finally {
    console.log("Finally block starts.");
    throw new Error("Error from finally"); // <-- This error takes precedence!
  }
}

try {
  testThrowInFinally();
} catch (e) {
  console.error("Caught exception:", e.message);
}
// Output:
// Try block starts.
// Finally block starts.
// Caught exception: Error from finally

Here, the throw in finally discards the return value from the try block. If the try block had thrown its own error, that original error would be lost, replaced by the one thrown from finally. This makes debugging a nightmare, the error you see isn’t the root cause…

Gotcha 3: break or continue inside finally (within a loop).

This is less common, but behaves similarly:

for (let i = 0; i < 3; i++) {
  try {
    console.log(`Try block, i = ${i}`);
    if (i === 1) {
       throw new Error("Problem at i=1");
    }
  } finally {
    console.log(`Finally block, i = ${i}`);
    if (i === 1) {
      console.log("Breaking from finally!");
      break; // <-- Abrupt completion from finally
    }
  }
  console.log(`After finally, i = ${i}`); // This won't run for i = 1
}
// Output:
// Try block, i = 0
// Finally block, i = 0
// After finally, i = 0
// Try block, i = 1
// Finally block, i = 1
// Breaking from finally!

The break in finally overrides the throw that would have otherwise occurred (or the normal completion if no error happened) and terminates the loop immediately.

Why Avoid Flow Control in finally?

The key takeaway is predictability. Code that relies on return, throw, break, or continue within a finally block is often:

  1. Hard to Read: It obscures the actual exit point and outcome of the try...catch structure.
  2. Error-Prone: It can silently swallow errors or intended return values.
  3. Difficult to Debug: The observed behavior (return value or thrown error) might not originate from where you expect.

Stick to using finally for its intended purpose: cleanup. If you need to alter control flow based on success or failure, do it within the try or catch blocks before finally runs.

A Glimpse of the Future: Explicit Resource Management (using TC39 Stage 3)

This discussion about reliable cleanup ties nicely into a feature that’s been maturing in TC39: Explicit Resource Management, primarily through the using and await using declarations. It’s currently a Stage 3 proposal, meaning it’s stable and implementations are appearing in runtimes (like Node.js, Deno, Bun) and browsers, often behind flags, or available via transpilers like Babel and TypeScript.

The core idea is to provide a dedicated syntax for resources that need deterministic cleanup. Objects can implement a [Symbol.dispose] (synchronous) or [Symbol.asyncDispose] (asynchronous) method.

// Hypothetical resource class
class MyResource {
  constructor() {
    console.log("Resource acquired");
    this.closed = false;
  }

  doWork() {
    if (this.closed) throw new Error("Resource is closed");
    console.log("Doing work...");
    // Simulating potential failure
    if (Math.random() > 0.5) throw new Error("Work failed!");
  }

  // Dispose method for 'using'
  [Symbol.dispose]() {
    console.log("Resource disposing (sync)");
    this.closed = true;
  }
}

// Usage with 'using'
function processWithUsing() {
  try {
    // 'resource' will be disposed automatically when leaving the block
    using resource = new MyResource();
    resource.doWork();
    console.log("Work succeeded!");
    return "Success";
  } catch (e) {
    console.error("Caught error in processWithUsing:", e.message);
    return "Failure"; // Resource still gets disposed!
  }
  // No 'finally' needed for resource disposal!
}

console.log("Final result:", processWithUsing());

// Possible Output 1 (Success):
// Resource acquired
// Doing work...
// Work succeeded!
// Resource disposing (sync)
// Final result: Success

// Possible Output 2 (Failure):
// Resource acquired
// Doing work...
// Caught error in processWithUsing: Work failed!
// Resource disposing (sync)
// Final result: Failure

The using declaration automatically calls [Symbol.dispose]() when the block scope is exited, regardless of whether it’s via normal completion, return, or throw. await using does the same for [Symbol.asyncDispose]() in async contexts.

While finally remains essential for general-purpose cleanup actions that aren’t tied to a specific object’s lifecycle, using offers a more structured and less error-prone way to handle resource management specifically. It directly addresses the common use case where finally is currently employed, often making the code cleaner and avoiding the temptation to put complex logic (or worse, flow control) inside finally. Definitely something to keep an eye on and start experimenting with where available.