skip to content

In a JavaScript promise chain, what does .finally() pass on to the next link, and in which cases can its callback change the outcome?

level: middleimportance: should knowfreq 50%

answer

  1. cleanup, not transformation
  2. no arguments to the callback
  3. return value discarded
  4. rejection keeps travelling
  5. throwing is the exception to transparency

basics

~20 s

Promise.prototype.finally runs its callback with no arguments and normally passes the original fulfilment value or rejection reason straight through, discarding whatever the callback returns. It only changes the outcome if the callback throws or returns a rejected promise.

solid answer

~50 s

`.finally(fn)` is for side effects — closing a spinner, releasing a lock — so it deliberately breaks the usual `.then` contract. The callback is called with **no arguments**, and its return value is thrown away: the promise `.finally` returns settles the same way the upstream one did, forwarding the fulfilment value or re-raising the original rejection reason. That means `.finally(() => 'x')` does not turn a value into `'x'`, and `.finally()` does **not** mark a rejection as handled — you still need a `.catch`. There are two ways the callback does matter: if it throws, or returns a promise that rejects, the derived promise rejects with that new reason and the original outcome is lost; and if it returns a *pending* thenable, settlement is delayed until that settles, though the original value still comes through. It was added in ES2018.

code

javascript · 15 lines
javascript
Promise.resolve('value')
  .finally(() => 'ignored')
  .then(v => console.log('1:', v));        // "1: value" — return discarded

Promise.resolve('value')
  .finally(v => console.log('2 arg:', v))  // "2 arg: undefined" — no argument
  .then(v => console.log('2:', v));        // "2: value"

Promise.reject(new Error('boom'))
  .finally(() => console.log('3: cleanup'))
  .catch(e => console.log('3 caught:', e.message)); // still rejected

Promise.resolve('value')
  .finally(() => { throw new Error('from finally'); })
  .catch(e => console.log('4:', e.message)); // "4: from finally"

go deeper

for a junior

Know that .finally runs cleanup either way, takes no arguments, and does not change the value flowing through. Remember you still need a .catch for errors.

for a middle

Explain the pass-through precisely — return value discarded, original value or reason forwarded — and name the exceptions: a throw or a returned rejected promise replaces the outcome, and a returned pending promise delays settlement.

for a senior

Show judgment about ordering and failure: put .finally before .catch so cleanup errors are caught too, and keep cleanup code defensive so it cannot mask the original failure reason in an incident.

for a principal

Own the convention for resource cleanup across the codebase — where release happens, how a failing cleanup is reported without destroying the original error, and how chains are prevented from ending on an unhandled rejection.

## What it is for `Promise.prototype.finally(onFinally)`, standardised in ES2018, exists for the cleanup that has to happen whichever way the operation went: hide the loading indicator, release the connection, stop the timer. Because that work is not interested in the result, `.finally` is defined to be *transparent*. ## The pass-through contract Three rules cover almost every case: 1. **The callback receives no arguments.** It cannot see the fulfilment value or the rejection reason. If you need them, use `.then(v => ..., e => ...)` or a `.catch` instead. 2. **The callback's return value is discarded.** The derived promise fulfils with the *original* value. 3. **A rejection keeps travelling.** `.finally` does not handle it; the derived promise rejects with the same reason, so a `.catch` is still required somewhere downstream or you get an unhandled rejection. ```js Promise.resolve('value') .finally(() => 'ignored') .then(v => console.log(v)); // "value" Promise.reject(new Error('boom')) .finally(() => console.log('cleanup')) // runs, but does not handle .catch(e => console.log('caught', e.message)); ``` It helps to think of `.finally(fn)` as roughly equivalent to `.then(v => { fn(); return v; }, e => { fn(); throw e; })`, with one important refinement described below. ## The two ways it *can* change the outcome **Throwing.** If `onFinally` throws, the derived promise rejects with that error, replacing the original outcome — including replacing an original rejection, whose reason is then lost unless the cleanup code captures it. That is the same hazard as a `finally` block in `try`/`catch`/`finally` swallowing the in-flight exception, and it is a good reason to keep cleanup code defensive. **Returning a rejected promise.** The refinement to the naive equivalence above is that a thenable returned from `onFinally` *is* respected for settlement timing and for failure — just not for its value. Concretely: if `onFinally` returns a pending promise, the derived promise waits for it, and then still settles with the original value or reason; if that promise rejects, the derived promise rejects with its reason instead. ```js Promise.resolve('value') .finally(() => new Promise(r => setTimeout(r, 100))) .then(v => console.log(v)); // logs "value" after ~100 ms Promise.resolve('value') .finally(() => Promise.reject(new Error('cleanup failed'))) .catch(e => console.log(e.message)); // "cleanup failed" — value is gone ``` That delay behaviour is genuinely useful: an asynchronous cleanup such as closing a handle can be awaited without changing what the caller receives. ## Common misuses **Trying to read the value.** `.finally(v => console.log(v))` always logs `undefined` — there is no argument. People discover this after a debugging session. **Trying to substitute a fallback.** `.finally(() => defaultValue)` does nothing to the chain's value. Recovering from a failure is `.catch`'s job; substituting a value on success is `.then`'s. **Assuming it handles errors.** Because the word reads like the end of a `try` block, people put `.finally` last and believe the chain is safe. It is not: the derived promise still rejects, so a chain ending in `.finally` with no `.catch` produces an unhandled rejection. **Order matters with `.catch`.** `p.catch(h).finally(f)` runs the handler first, so `f` sees an already-recovered chain and the derived promise is fulfilled. `p.finally(f).catch(h)` runs cleanup first and lets `h` handle the original error — and also handles an error thrown by `f` itself. The second order is usually what you want when cleanup can fail. ## Where it returns a new promise, like everything else `.finally` follows the same structural rule as the rest of the chain: it returns a **new** promise rather than the receiver. The transparency is in the *value*, not the identity — so `p.finally(f) !== p`, and attaching further links continues from the derived promise. Keep that in mind when you store a promise in a variable and then call `.finally` on it without reassigning: the cleanup still runs, but any error it introduces lives on a promise nobody is holding, which is its own quiet failure mode.

  • Does putting `.finally()` at the end of a chain protect you from unhandled rejections?
    No. `.finally` forwards the rejection reason, so the promise it returns is rejected too. Ending a chain with `.finally` and no `.catch` still produces an unhandled rejection — the cleanup simply runs on the way past. Handling requires a `.catch` (or a rejection handler on `.then`) somewhere downstream.
  • What happens if the `.finally` callback returns a promise?
    Settlement waits for it, but the value is still discarded: once it fulfils, the chain continues with the original value or rejection reason. If it rejects, however, the derived promise rejects with that reason and the original outcome is lost. This makes asynchronous cleanup awaitable without changing what the caller receives.
  • Does `.catch(h).finally(f)` behave the same as `.finally(f).catch(h)`?
    No. In the first, `h` recovers before cleanup, so `f` sees a fulfilled chain and any error `f` throws is unhandled. In the second, cleanup runs first and `h` catches both the original failure and anything `f` throws. Prefer that order when cleanup can itself fail.

saying these in an interview costs you the question

  • Expecting the .finally callback to receive the fulfilment value
  • Thinking a return inside .finally replaces the chain's value
  • Believing .finally handles a rejection like a catch
  • Assuming a throw inside .finally is swallowed
  • Treating .finally as returning the same promise it was called on

context