In JavaScript, why is writing a `return` statement inside a `finally` block treated as a bug, and what happens to an error that was already propagating when the `finally` block itself throws?
answer
- abrupt completion beats pending completion
- the block can overwrite the outcome
- return in finally discards the throw
- cleanup error replaces the real error
- linters flag return inside finally
basics
~20 sAn abrupt completion inside a finally block replaces whatever the try statement was already doing. A return there discards a pending error entirely, and a throw there replaces the original error, so the real failure disappears and only the cleanup failure survives.
solid answer
~50 sA `try` statement carries a pending completion while `finally` runs - a value to return, or an error to propagate. If the `finally` block itself completes abruptly with `return`, `throw`, `break` or `continue`, that new completion *replaces* the pending one. So `try { throw new Error('real'); } finally { return 'ok'; }` returns `'ok'` and the error is gone - no stack trace, no log, no rejection, just a plausible-looking success. The same rule bites accidentally when cleanup throws: if `resource.close()` fails while an error is unwinding, the close error replaces the original and you debug the symptom instead of the cause. The discipline is that `finally` must never complete abruptly - no `return` or `break` in it, and cleanup that can fail is wrapped in its own `try/catch` that logs rather than throws. ESLint's `no-unsafe-finally` rule exists for exactly the first half of this.
code
javascript · 21 linesfunction swallow() {
try {
throw new Error('real failure');
} finally {
return 'ok';
}
}
console.log(swallow()); // 'ok' - the error was discarded
function safeRead(resource) {
try {
return resource.read();
} finally {
try {
resource.close();
} catch (closeError) {
console.warn('close failed', closeError);
}
}
}go deeper
Remember the headline rule: a return inside finally overrides whatever the try statement was doing, including an error on its way out, so the failure disappears. Avoid writing one.
Explain it with the mechanism - the pending completion is replaced by the finally block's abrupt completion - and extend it to break, continue and to cleanup calls that throw while an error is already propagating.
Show you can diagnose it in a live system: name the symptoms, the defensive cleanup pattern that preserves the primary error, and the mechanical review or lint check that stops it recurring.
Own the standard for the codebase - a lint rule enforced in CI, a convention for fallible cleanup, and an argument for why silent error absorption is an observability defect rather than a stylistic one.
## Pending completions, and who wins Every statement produces a completion: normal, return, throw, break or continue, each with a value. A `try` statement runs its block, records whatever completion came out, runs `finally`, and then resumes the recorded completion - *unless* the `finally` block produced an abrupt completion of its own. In that case the finally block's completion wins outright and the recorded one is discarded, unlogged and unrecoverable. That single rule explains every behaviour in this area. ## return inside finally ```js function swallow() { try { throw new Error('real failure'); } finally { return 'ok'; } } swallow(); // 'ok' - the error no longer exists ``` The throw was recorded, the finally block returned, and the return replaced it. Nothing observed the error: no handler ran, no `unhandledrejection` or top-level handler fired, no stack was printed. The function looks like it succeeded. The same override applies to an ordinary value: ```js function shadow() { try { return 'from try'; } finally { return 'from finally'; // wins } } shadow(); // 'from finally' ``` This is why linters ban it outright - ESLint's core `no-unsafe-finally` rule flags `return`, `throw`, `break` and `continue` inside a finally block. The construct is legal, occasionally used deliberately, and almost always a mistake in real code because its effect on error propagation is invisible at the call site. ## break and continue do it too Inside a loop, a `break` or `continue` in a finally block is an abrupt completion just like a return: ```js for (const id of ids) { try { load(id); // may throw } finally { if (shouldStop) break; // discards the error, exits the loop quietly } } ``` The loop ends, the error vanishes, and the log shows a clean run over fewer items than expected. This variant is harder to spot in review than the `return` one because the abrupt statement reads like ordinary loop logic. ## When the cleanup itself throws The accidental version of the same rule is far more common than the deliberate one: ```js function read(resource) { try { return resource.read(); // throws: 'connection reset' } finally { resource.close(); // also throws: 'already closed' } } ``` The caller receives `already closed`. The `connection reset` error - the one that explains what actually went wrong - is gone. Whole debugging afternoons are spent on this: the symptom that reaches the logs is a secondary failure from the cleanup path, and the cleanup path throws *because* the primary failure left things in a bad state, so the two are correlated in exactly the way that hides the cause. ## The discipline Treat `finally` as a block that must always complete normally: - never write `return`, `throw`, `break` or `continue` directly in it - turn the linter rule on and leave it on; - if a cleanup call can throw, give it its own `try/catch` inside the finally block and report the secondary failure without letting it escape; - keep cleanup narrow: release what this scope acquired, nothing more, so there is less that can fail. ```js function read(resource) { try { return resource.read(); } finally { try { resource.close(); } catch (closeError) { console.warn('close failed', closeError); } } } ``` Now the primary error propagates, and the cleanup failure is still visible in the logs rather than silently displacing it. ## Diagnosing it in an existing codebase Symptoms worth recognising: a function that reports success on inputs that clearly cannot succeed; a stack trace whose top frame is a `close`, `release` or `unlock` call and whose message describes a state problem rather than the operation you asked for; error counts in monitoring that are lower than the failure rate users report. All three point at a finally block that is deciding the outcome of the statement. Grep for `finally` blocks containing `return` first - that is a mechanical, high-yield search - then review the ones that call anything fallible. ## Where the boundary is Occasionally `return` in `finally` is used on purpose, in a wrapper that is meant to convert every outcome into a value. Even then, prefer expressing that intent explicitly with a `catch` clause that returns, so a reader can see which errors are being absorbed. The problem with the finally form is not that absorbing errors is always wrong - it is that the code gives no signal that it is happening.
- Do break and continue inside a finally block have the same effect as return?Yes - all four of `return`, `throw`, `break` and `continue` are abrupt completions, and any of them inside a finally block replaces the pending completion of the try statement. A `break` in a finally block inside a loop quietly ends the loop and discards an error that was propagating, which is even easier to miss in review than a return.
- If cleanup can fail, how do you report the cleanup failure without losing the original error?Wrap the fallible cleanup in its own try/catch inside the finally block and handle the secondary failure there - log it, count it, attach it to context - but never rethrow. The primary error then continues to propagate untouched, and the cleanup failure is still visible to whoever reads the logs.
- What symptoms in production suggest a finally block is swallowing errors?Functions reporting success on inputs that cannot possibly succeed; monitored error rates lower than the failure rate users describe; and stack traces whose top frame is a close, release or unlock call with a state-related message. Grepping for return, break or continue inside finally blocks usually finds the culprit quickly.
- Is a return inside finally ever legitimate?Occasionally, in a wrapper deliberately built to turn every outcome into a value. Even then the catch clause expresses it better, because a reader can see which errors are absorbed. The objection is not that absorbing errors is always wrong - it is that the finally form absorbs them with no visible signal at the call site.
saying these in an interview costs you the question
- Thinks a return in finally runs after the try's return, harmlessly
- Says an error thrown in try always reaches the caller regardless
- Believes both errors surface, or arrive as an AggregateError
- Treats cleanup as code that cannot itself fail
- Considers return in finally a style preference, not a defect