941 words
5 minutes

Promise.all Rejects Early: Wait Before Cleaning Up

2026-10-06
Backend
JavaScript
/
Node.js
/
Troubleshooting

Promise.all() can reject while another task is still using a resource. A finally block after that await can therefore close a connection or remove a temporary directory before the remaining task finishes.

Choose the lifetime you need: wait for every task with Promise.allSettled(), or request cancellation through an API that supports it and then wait for every task to settle. Catching the first error alone does not establish that cleanup can begin.

Reproduce cleanup happening before the last write#

Save the following as promise-cleanup.mjs and run node promise-cleanup.mjs. The complete fixture was executed on macOS with Node.js 24.13.0 on October 6, 2026. It uses in-memory event records rather than a real database or filesystem.

The first two cases use an explicitly released gate, so their ordering does not depend on a guessed timer duration. The third uses Node’s abortable promise timer to demonstrate cancellation of an operation that actually accepts a signal.

import assert from 'node:assert/strict';
import { setTimeout as delay } from 'node:timers/promises';
async function earlyCleanup() {
const events = [];
let release;
const gate = new Promise((resolve) => { release = resolve; });
const pending = (async () => {
await gate;
events.push('write');
})();
const tasks = [Promise.reject(new Error('failed')), pending];
try {
await Promise.all(tasks);
} catch {
events.push('caught');
} finally {
events.push('cleanup');
}
release();
await Promise.allSettled(tasks);
return events;
}
async function waitBeforeCleanup() {
const events = [];
let release;
const gate = new Promise((resolve) => { release = resolve; });
const pending = (async () => {
await gate;
events.push('write');
})();
const tasks = [Promise.reject(new Error('failed')), pending];
const settled = Promise.allSettled(tasks);
release();
const results = await settled;
events.push(results.map((result) => result.status).join('/'));
events.push('cleanup');
return events;
}
async function abortBeforeCleanup() {
const events = [];
const controller = new AbortController();
const pending = (async () => {
try {
await delay(1000, undefined, { signal: controller.signal });
events.push('write');
} catch (error) {
events.push(error.name);
throw error;
}
})();
const tasks = [Promise.reject(new Error('failed')), pending];
try {
await Promise.all(tasks);
} catch (error) {
events.push(error.message);
controller.abort();
await Promise.allSettled(tasks);
} finally {
events.push('cleanup');
}
return events;
}
const early = await earlyCleanup();
const waited = await waitBeforeCleanup();
const aborted = await abortBeforeCleanup();
assert.deepEqual(early, ['caught', 'cleanup', 'write']);
assert.deepEqual(waited, ['write', 'rejected/fulfilled', 'cleanup']);
assert.deepEqual(aborted, ['failed', 'AbortError', 'cleanup']);
console.log('all:', early.join(' -> '));
console.log('allSettled:', waited.join(' -> '));
console.log('abort + drain:', aborted.join(' -> '));

The assertions pass with this output:

all: caught -> cleanup -> write
allSettled: write -> rejected/fulfilled -> cleanup
abort + drain: failed -> AbortError -> cleanup

In earlyCleanup(), the gate remains closed until after finally runs. The later write event proves that catching the aggregate rejection did not finish the sibling task. The final allSettled() only drains that task so the demonstration can report its complete event list; it comes too late to protect the earlier cleanup.

Choose a completion boundary#

For an ordinary array of native promises, these choices give different boundaries:

RequirementPatternWhat the fixture proves
Every result is required, but you want the first failure immediatelyawait Promise.all(tasks)The caller can proceed before another task finishes
Cleanup must wait for every tracked task, including failed onesawait Promise.allSettled(tasks)The write precedes cleanup; the failed result remains visible
On failure, stop supported pending work before cleanupAbort the shared signal, then await Promise.allSettled(tasks)The timer rejects with AbortError; its write never runs

The ECMAScript Promise.all algorithm attaches fulfillment and rejection handlers to the inputs. It does not provide an operation-cancellation step. MDN documents the early rejection and continued operations.

If every task should finish despite one failure, collect outcomes with Promise.allSettled(). Inspect each status; waiting successfully does not mean every task succeeded. In the fixture, rejected/fulfilled is the result order for the input array.

Abort, drain, then preserve the failure#

The cancellation case works because Node’s promise timers accept an AbortSignal. Calling abort() rejects the pending timer with AbortError. The catch block waits for both task promises before finally records cleanup.

In application code, keep the same order and rethrow the original error after draining. This helper is a complete, syntax-checked pattern; unlike the fixture above, it was not exercised against application resources:

async function runBatch(startTasks, cleanup) {
const controller = new AbortController();
let tasks = [];
try {
// startTasks must return an array synchronously without throwing
// after starting work. Every task must honor the supplied signal
// if cancellation is required.
tasks = startTasks(controller.signal);
return await Promise.all(tasks);
} catch (error) {
controller.abort();
await Promise.allSettled(tasks);
throw error;
} finally {
await cleanup();
}
}

The contract matters: if a task launches background work and resolves before that work stops, draining its promise cannot protect the resource. If a task ignores the signal, draining waits for its normal completion. If a task never settles, cleanup waits indefinitely; this helper has no deadline. A rejecting cleanup() also replaces the return value or original error, so define how your application reports cleanup failures.

Cancellation does not roll back completed writes or prove that a remote server stopped processing a request. This example verifies a local timer only; database drivers, filesystem operations, browser requests, and server-side effects need their own cancellation checks.

Keep task failure separate from HTTP status#

A sibling fetch returning HTTP 404 will not trigger this cancellation path unless your task checks the response and throws. Use the status-before-JSON pattern when HTTP failures should reject the task.

Also, later rejections from the same native promises already passed to Promise.all() are handled by that combinator. They are not automatically unhandled rejections merely because another input failed first. Detached promises or ignored promises returned by additional .then() or .finally() calls are a separate diagnosis.

Before releasing a shared resource, verify that every operation using it is represented by a promise you have awaited through settlement. Choose cancellation separately, based on the underlying API.

References: ECMAScript: PerformPromiseAll, MDN: Promise.all, MDN: Promise.allSettled, Node.js: Cancelling timers. Checked October 6, 2026; fixture executed on Node.js 24.13.0.

Promise.all Rejects Early: Wait Before Cleaning Up
https://laplusda.com/en/posts/promise-all-rejects-cleanup-before-settled/
Author
Zero
Published at
2026-10-06
License
CC BY-NC-SA 4.0