542 words
3 minutes

JavaScript Promise finally Masks the Original Error: Keep Both Failures

2026-11-09
Frontend
JavaScript
/
Promise
/
Node.js
Contents

If a write rejects and the cleanup callback in write().finally(close) also rejects, the caller sees the close failure. The original write promise still has its original rejection reason. It’s the promise returned by .finally() that rejects with the cleanup error.

A successful cleanup passes through the original result or rejection. If the callback throws or returns a promise that rejects, the cleanup failure takes its place, as specified by ECMAScript’s Promise.prototype.finally algorithm. If both failures matter, capture the work failure before cleanup and carry both reasons to the caller.

Check what reaches the caller#

This wrapper treats cleanup as required. It runs cleanup once after work settles, propagates a single failure unchanged, and uses AggregateError when both steps fail. The work reason is first in errors; the cleanup reason is second. That order is the wrapper’s contract.

Save this as cleanup.mjs and run node cleanup.mjs. The example uses simulated writes and closes and was checked on Node.js 24.13.0 on macOS arm64.

import assert from "node:assert/strict";
async function runWithCleanup(work, cleanup) {
let workFailed = false;
let workError;
try {
return await work();
} catch (error) {
workFailed = true;
workError = error;
throw error;
} finally {
try {
await cleanup();
} catch (cleanupError) {
if (!workFailed) throw cleanupError;
throw new AggregateError(
[workError, cleanupError],
"Work and cleanup both failed",
);
}
}
}
const writeError = new Error("write failed");
const closeError = new Error("close failed");
const write = async () => "saved";
const close = async () => {};
const failWrite = async () => { throw writeError; };
const failClose = async () => { throw closeError; };
await assert.rejects(failWrite().finally(failClose), e => e === closeError);
assert.equal(await runWithCleanup(write, close), "saved");
await assert.rejects(runWithCleanup(failWrite, close), e => e === writeError);
await assert.rejects(runWithCleanup(write, failClose), e => e === closeError);
await assert.rejects(runWithCleanup(failWrite, failClose), e =>
e instanceof AggregateError &&
e.errors[0] === writeError && e.errors[1] === closeError
);
await assert.rejects(
runWithCleanup(() => Promise.reject(undefined), failClose),
e => e instanceof AggregateError &&
e.errors.length === 2 &&
e.errors[0] === undefined && e.errors[1] === closeError
);
console.log("Six checks passed");

Expected output:

Six checks passed

The first assertion reproduces the masking with plain .finally(). The next four cover every success and failure combination in the wrapper. The last checks why the code uses a separate workFailed flag: a promise can reject with undefined, so testing the reason for truthiness would misclassify that rejection as success.

Keep return await work() inside the try. With return work(), the function can enter its finally block before the returned promise settles, and the local catch won’t capture a later rejection. Likewise, the cleanup callback must return or await all the cleanup work it owns. A detached promise is outside the wrapper’s error handling.

Decide which failure belongs in the result#

Use plain .finally() when cleanup cannot fail or when its failure should take precedence. Use the wrapper when cleanup is required and the caller can inspect AggregateError.errors. If existing callers branch on the type of the original error, handle the aggregate at that boundary before replacing the chain. For best-effort cleanup, catch and report the cleanup failure separately if the work’s outcome must remain unchanged.

This wrapper waits for the promise returned by work. If that promise rejects early while sibling operations are still using the resource, first wait for those operations to settle before cleanup.

References:

JavaScript Promise finally Masks the Original Error: Keep Both Failures
https://laplusda.com/en/posts/javascript-promise-finally-masks-error/
Author
Zero
Published at
2026-11-09
License
CC BY-NC-SA 4.0