Promise.all rejects fast, but the other promises keep running
published
TL;DR
Promise.all settles as soon as one input rejects, but it has no power to stop the others. They keep running, finish after your catch block has already returned, and their side effects land anyway. If a second one also rejects, that error is discarded silently — no unhandledRejection, no log. Cancellation is your job: thread an AbortSignal through and abort it yourself.
The problem
You fire three writes together, one fails, you catch it and return an error. You assume nothing happened. Two of the three writes happened.
const task = (ms, label) =>
new Promise((resolve) =>
setTimeout(() => {
console.log(`${label} finished`);
resolve(label);
}, ms),
);
try {
await Promise.all([
task(100, 'A'),
Promise.reject(new Error('B failed')),
task(300, 'C'),
]);
} catch (err) {
console.log('caught:', err.message);
}
console.log('handler returned, await is over');
On Node v26.2.0 that prints:
caught: B failed
handler returned, await is over
A finished
C finished
Read the order carefully. handler returned comes second, and A and C report in afterwards. By the time you were writing your error response, those two tasks were still in flight — and they completed normally, 100 ms and 300 ms later, long after the request you were serving had moved on.
Why it happens
A promise is not a task. It is a handle on a result that is already being computed. By the time Promise.all receives the array, every one of those operations has already started — task(100, 'A') ran the moment the array literal was evaluated, before Promise.all was even called. There is no handle to pull, because JavaScript promises have no cancellation in the language.
So Promise.all does the only thing it can: it stops listening. It rejects its own promise with the first reason it sees and leaves the rest to finish into the void.
That has a second consequence, and this one is worse because it is invisible:
process.on('unhandledRejection', (e) => console.log('!! unhandledRejection:', e.message));
const slowFail = new Promise((_, rej) =>
setTimeout(() => rej(new Error('second failure')), 50),
);
try {
await Promise.all([Promise.reject(new Error('first failure')), slowFail]);
} catch (err) {
console.log('caught:', err.message);
}
Output:
caught: first failure
second failure never appears — not as a caught error, not as an unhandledRejection. Promise.all attached its own handler to every input, so the runtime considers that rejection handled; but its result promise was already settled, so the second reason is dropped on the floor. You lose the error entirely. If B and C both failed for the same underlying reason, you will debug B and never learn C existed.
Here is how the four combinators actually differ:
| Combinator | Settles when | On rejection | The other promises |
|---|---|---|---|
Promise.all | all fulfil, or first rejects | rejects with the first reason | keep running; later rejections discarded |
Promise.allSettled | every input settles | never rejects | all run to completion, every outcome reported |
Promise.any | first fulfils, or all reject | AggregateError after all reject | keep running |
Promise.race | first input settles | rejects if that first one rejected | keep running |
Note that none of the four cancels anything. The column is the same for all of them.
What to do
If you need to know every outcome, use allSettled. It never rejects, so you inspect statuses instead of catching:
const results = await Promise.allSettled(tasks);
const failed = results.filter((r) => r.status === 'rejected');
if (failed.length) {
console.error(`${failed.length} of ${results.length} failed`);
for (const f of failed) console.error(f.reason);
}
This is the fix for the swallowed-second-error problem. You get all the reasons, not just the fastest one.
If you actually want to stop the work, pass a signal and abort it. Write the tasks as functions that take a signal, so nothing starts until you call them:
async function allOrAbort(taskFns) {
const controller = new AbortController();
try {
return await Promise.all(taskFns.map((fn) => fn(controller.signal)));
} catch (err) {
controller.abort();
throw err;
}
}
// each task decides what aborting means for it
const rows = await allOrAbort([
(signal) => fetch('https://example.com/a', { signal }),
(signal) => fetch('https://example.com/b', { signal }),
]);
fetch honours signal and rejects with an AbortError. Anything you write yourself has to check signal.aborted at its own checkpoints, or listen for the signal’s abort event — aborting does not interrupt a running function.
If the operations have side effects, make the failure recoverable rather than preventable. You cannot un-send a webhook. Either make each write idempotent and retry, or sequence them so a failure stops the next one from starting:
for (const fn of taskFns) {
await fn(); // a throw here means later ones never begin
}
That trades all the concurrency away, which is the actual cost of ordering guarantees.
Caveats
Promise.allis the right tool most of the time. Reads, idempotent lookups, anything with no side effects: fail-fast is what you want, and the wasted work is only wasted CPU. This post is about the cases where the leftover work writes something.AbortControlleris not universal.fetchsupports it; most database drivers do not. Check the library. Passing a signal to something that ignores it gives you the illusion of cancellation and none of the behaviour.- Aborting is cooperative, not preemptive. A synchronous CPU-bound loop will not stop mid-iteration because you called
abort(). - Sequencing is not a transaction. Stopping the next write does not roll back the previous ones. If you need atomicity, you need it at the storage layer.
- The exact interleaving of the console output depends on the timers in the example. The ordering that matters —
catchrunning before the other tasks finish — is guaranteed by the spec, not by the timings picked here.