ESC

Type to search the knowledge base.

Promise.finally Polyfill

Polyfill Promise.prototype.finally: run cleanup on fulfill and reject, pass the original value through, and wait if the callback returns a thenable.

intermediate5 min read
  • javascript
  • promises
  • polyfill
  • async

The broken Promise.finally looks like .then(cb, cb) with the callback receiving the value. Cleanup then either uses an argument the spec never passes, or returns a new value and eats the original fulfillment. The other broken form does not Promise.resolve the callback result, so a thenable returned from cleanup is ignored and the chain continues before the lock is released.

finally runs on fulfill and on reject. The callback gets no arguments. The original value or reason is passed through, unless the callback throws or returns a rejected thenable. In that case the new failure replaces the original.

Prefer a standalone myFinally(promise, onFinally). Patching Promise.prototype.finally is interview convention and mutates a shared builtin.

Key takeaways

  • Callback arity is zero. Do not read the settled value inside finally.
  • Fulfillment value and rejection reason pass through on a successful callback.
  • If the callback returns a thenable, wait for it. Then still pass the original through.
  • If the callback throws or rejects, that reason replaces the original outcome.
  • Promise.resolve(onFinally()) is the wait-and-adopt step.

Cleanup that must not eat the result

function myFinally(promise, onFinally) {
  return Promise.resolve(promise).then(
    (value) => Promise.resolve(onFinally()).then(() => value),
    (reason) =>
      Promise.resolve(onFinally()).then(() => {
        throw reason;
      }),
  );
}

Both branches call onFinally with no args. Both wait on Promise.resolve(...) so a returned thenable delays the chain. The fulfillment branch then returns the original value, not whatever cleanup returned. The rejection branch rethrows the original reason after cleanup succeeds.

If onFinally throws synchronously, Promise.resolve(onFinally()) rejects, the inner .then(() => value) is skipped, and the result rejects with the cleanup error. Same if cleanup returns Promise.reject(x): x wins, the original value is gone. That is spec, not a courtesy.

Original Cleanup finally result
Fulfill v Returns / fulfills Fulfill v
Reject e Returns / fulfills Reject e
Either Throws or rejects x Reject x
Either Returns a pending thenable Wait, then apply the rows above
myFinally(Promise.resolve(10), () => {
  console.log('clean'); // no args
  return 99; // ignored
}).then((v) => v); // 10

myFinally(Promise.reject(new Error('orig')), () => {
  /* close db */
}).catch((err) => err.message); // 'orig'

myFinally(Promise.resolve(10), () => Promise.reject(new Error('cleanup'))).catch(
  (err) => err.message,
); // 'cleanup'

A thenable used as delay:

function unlock() {
  return new Promise((res) => setTimeout(res, 10));
}

myFinally(fetch('/api'), unlock).then((res) => res.json());

The JSON work waits until unlock fulfills. unlock’s fulfillment value is discarded. If unlock rejects, you never parse, even if fetch was fine.

Prototype form, if they ask

function installFinally(PromiseCtor) {
  // Interview convention: mutates the prototype.
  PromiseCtor.prototype.finally = function (onFinally) {
    return myFinally(this, onFinally);
  };
}

Native finally uses the species constructor. A whiteboard answer that returns this.then(...) on the receiver is enough. Do not chase @@species unless they are testing subclassing.

If onFinally is not callable, native finally still chains through then with that non-function, which means identity pass-through. Calling a non-function and exploding is a harsher polyfill. Mention the spec if you skip the IsCallable branch.

Footguns in a Promise.finally polyfill

.then(cb, cb) passes value or reason as the first argument. Logging it looks useful and is wrong for finally. It also means a callback that returns a value replaces the chain’s fulfillment, which finally must not do on success.

Forgetting to rethrow in the reject branch turns a failure into a success with undefined (or with cleanup’s return). Tests that only check “callback ran” will not catch that.

Forgetting Promise.resolve around the callback result skips waiting. A mutex unlock that returns a promise will run the next critical section while the lock is still held.

finally does not catch. Errors from earlier still propagate after cleanup, unless cleanup itself rejects and replaces them. People who treat finally as catch plus rethrow miss the replace rule.

The callback runs as a microtask reaction, like any then. It does not run synchronously when you attach finally to an already-settled promise. See the event loop.

async/await try/finally is the same idea for a single function body: cleanup runs on both paths, and a throw in finally replaces the original. Promise.prototype.finally is the version that attaches to someone else’s promise, including a fetch you did not wrap in async.

Do not use finally to read the payload for logging and then return it. Use then for that. finally is for locks, spinners, clearTimeout, and “this slot is no longer in flight.” A spinner setBusy(false) that returns false must not become the chain’s fulfillment value. The pass-through in myFinally is what prevents that.

Interview answer (out loud)

“finally runs the same cleanup on fulfill and reject, with no arguments. I wait on Promise.resolve of the callback so a returned thenable delays the chain. On success I return the original value; on failure I rethrow the original reason. If cleanup throws or rejects, that error replaces the original. I would not write then(cb, cb), because that both passes an argument and lets a return value eat the result.”

Further reading

Related guides