“What does finally do on a promise, and how is it different from putting the same code in both then and catch?” Interviewers ask this when they want to check how carefully you think about error propagation. Most candidates know finally is for cleanup, such as hiding a spinner or closing a connection. Fewer know that it is transparent by design, that it can still change the outcome, and that try/finally with a return statement can quietly erase an exception.
These details matter in production code, where a cleanup step that throws can replace a meaningful database error with a confusing “cannot read properties of undefined” from the cleanup itself.
Before you start
You should know how then and catch chain, that each returns a new promise, and how async/await maps onto them (await either produces the value or throws the rejection reason). Familiarity with synchronous try/catch/finally helps for the comparison.
The short answer
promise.finally(fn) calls fn with no arguments when the promise settles either way, and the returned promise settles with the original value or rejection reason; the callback’s return value is ignored. Two things can change that: if fn throws or returns a rejected promise, that new error replaces the original outcome; and if fn returns a promise, the chain waits for it first. Use finally for cleanup that must not affect the result, and keep cleanup code from throwing.
How it works
Think of p.finally(fn) as roughly equivalent to this, with one difference: fn gets no arguments because it should not care whether the operation succeeded.
const finallyLike = (p, fn) =>
p.then(
(value) => Promise.resolve(fn()).then(() => value),
(reason) => Promise.resolve(fn()).then(() => { throw reason; }),
);
const ok = await finallyLike(Promise.resolve('data'), () => 'ignored');
console.log(ok); // data
await finallyLike(Promise.reject(new Error('db down')), () => 'ignored')
.catch((e) => console.log(e.message)); // db downThe real method behaves the same way: the callback’s normal return value is discarded, and the original value or reason flows through.
const value = await Promise.resolve(42).finally(() => 'replacement');
console.log(value); // 42
const args = [];
await Promise.resolve('x').finally((...a) => args.push(a.length));
console.log(args); // [ 0 ]But an exception or rejected promise from the callback is not ignored. It becomes the new rejection reason and the original outcome is lost.
await Promise.reject(new Error('original'))
.finally(() => { throw new Error('cleanup failed'); })
.catch((e) => console.log(e.message)); // cleanup failedStep-by-step walkthrough
Step 1: Use finally for state that must reset
The classic case is UI state such as a loading flag. Without finally, you would duplicate the reset in both success and failure paths and risk forgetting one.
const ui = { loading: false };
async function load(fetcher) {
ui.loading = true;
return fetcher().finally(() => { ui.loading = false; });
}
await load(async () => 'rows');
console.log(ui.loading); // false
await load(async () => { throw new Error('500'); }).catch(() => {});
console.log(ui.loading); // falseStep 2: Let async cleanup complete before continuing
If the callback returns a promise, the chain waits for it. That is exactly what you want when closing a connection: callers should not proceed until the resource is released.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const log = [];
await Promise.resolve('result')
.finally(async () => {
await sleep(20);
log.push('connection closed');
})
.then((v) => log.push(`got ${v}`));
console.log(log); // [ 'connection closed', 'got result' ]Step 3: Protect the original error from failing cleanup
If cleanup can throw, wrap it so the operation’s error survives. Log cleanup failures separately; they are useful, but they should not replace the cause.
async function withConnection(open, work) {
const conn = await open();
try {
return await work(conn);
} finally {
try {
await conn.close();
} catch (closeError) {
console.log('close failed:', closeError.message);
}
}
}
const open = async () => ({ close: async () => { throw new Error('socket reset'); } });
await withConnection(open, async () => { throw new Error('query timeout'); })
.catch((e) => console.log('caller sees:', e.message));
// close failed: socket reset
// caller sees: query timeoutWhen you need both errors reported to the caller, combine them with new AggregateError([workError, closeError]) or set the work error’s cause.
Step 4: Avoid control flow statements in try/finally
In synchronous and async functions, a return inside finally overrides both a pending return and a thrown error. The error disappears without a trace.
async function save() {
try {
throw new Error('disk full');
} finally {
return 'saved';
}
}
console.log(await save()); // savedESLint’s no-unsafe-finally rule flags this. The promise method cannot do the same thing with a plain return, which is one reason it is safer for cleanup.
Worked scenario
A checkout flow released an inventory lock in finally. The lock client threw when the lock had already expired, so a real payment failure such as “card declined” was reported to users as “lock not held,” and support could not see why payments failed.
const lock = { held: false, release() { if (!this.held) throw new Error('lock not held'); this.held = false; } };
async function checkoutBroken(pay) {
lock.held = true;
return pay().finally(() => lock.release());
}
const slowDeclinedPayment = async () => {
lock.held = false; // the lock expires while the payment provider is slow
throw new Error('card declined');
};
await checkoutBroken(slowDeclinedPayment)
.catch((e) => console.log('user sees:', e.message)); // user sees: lock not heldslowDeclinedPayment simulates the lock expiring during a slow payment call. The fix makes release idempotent and keeps cleanup failures from masking the cause.
const lock = { held: false, release() { if (!this.held) throw new Error('lock not held'); this.held = false; } };
async function checkout(pay) {
lock.held = true;
return pay().finally(() => {
try {
if (lock.held) lock.release();
} catch (e) {
console.log('release warning:', e.message);
}
});
}
const slowDeclinedPayment = async () => {
lock.held = false;
throw new Error('card declined');
};
await checkout(slowDeclinedPayment)
.catch((e) => console.log('user sees:', e.message)); // user sees: card declined
console.log(await checkout(async () => 'paid'), lock.held); // paid falseCommon mistake
- “finally receives the result or error.” It receives nothing. If cleanup depends on the outcome, use
thenandcatch. - “finally can change the resolved value by returning something.” Its normal return value is ignored. Only a throw or rejection changes the outcome.
- “finally swallows errors.” The promise method passes them through; it is
returninside atry/finallyblock that swallows them. - Throwing from cleanup. That replaces the original error, often with a less useful one.
- Forgetting to return the finally chain.
p.finally(fn); return p;returns a promise that does not wait for cleanup, and if cleanup rejects, that rejection is unhandled. - Using finally as catch. A chain ending in
.finally()still rejects; you need acatchsomewhere to handle the error.
Verify the behavior
These tests capture every rule the interviewer is likely to probe.
import test from 'node:test';
import assert from 'node:assert/strict';
test('passes through values and ignores the callback return', async () => {
assert.equal(await Promise.resolve(1).finally(() => 2), 1);
});
test('passes through rejections', async () => {
await assert.rejects(Promise.reject(new Error('a')).finally(() => {}), { message: 'a' });
});
test('a throwing callback replaces the outcome', async () => {
await assert.rejects(Promise.resolve(1).finally(() => { throw new Error('b'); }), { message: 'b' });
});
test('return in try/finally hides the error', async () => {
const f = async () => { try { throw new Error('c'); } finally { return 'ok'; } };
assert.equal(await f(), 'ok');
});Run node --test finally.test.mjs; all four pass.
Follow-up questions
When was Promise.prototype.finally added? ES2018. It is available in all modern browsers and Node versions.
Is .finally() the same as try/finally with await? Very close. Both run cleanup on either outcome and pass the original result through. The difference is that try/finally can contain return, break or throw that override the outcome, and its cleanup runs inside the same function scope.
Does finally run if the promise never settles? No. A pending promise never triggers any handler, which is why timeouts matter.
Can finally recover from an error? No. To turn a rejection into a fallback value, use catch.
Where should finally sit in a chain? Usually last, after catch, so cleanup runs once whatever happened. If you put it before catch, it still runs, but a failure inside the cleanup will then be handled by that same catch, which may be what you want for logging and is worth saying explicitly.
How do you test cleanup code? Force both paths: make the operation succeed and fail, and assert the resource was released in each case. Then make the cleanup itself fail and assert that the caller still sees the original error. Those three tests catch nearly every bug described in this article.
Interview exercise
Predict the output of this script, line by line.
const a = await Promise.resolve('A').finally(() => 'B');
console.log(a);
const b = await Promise.reject(new Error('C'))
.finally(() => Promise.resolve('D'))
.catch((e) => e.message);
console.log(b);
const c = await Promise.resolve('E')
.finally(() => Promise.reject(new Error('F')))
.catch((e) => e.message);
console.log(c);
// A
// C
// FAnswer and reasoning
The first finally returns 'B', which is ignored because a normal return never changes the outcome, so a is 'A'. In the second chain, the callback returns a fulfilled promise; finally waits for it and then re-rejects with the original error, so catch receives C. In the third chain, the callback returns a rejected promise, and a rejection from cleanup replaces the original value, so the success 'E' is lost and catch receives F. The rule to state: finally is transparent unless the cleanup itself fails, and when it fails, its error wins.
Continue learning
Keep going with the JavaScript interview questions and the JavaScript MCQs. Related notes: Promise combinators and failure modes, unhandled promise rejections and custom error classes. Official reference: Promise.prototype.finally on MDN.