When Promise.all() rejects, the promise it returns settles with the first rejection reason, and that is all that changes. Nothing is cancelled. The other input promises keep going, the operations behind them keep running, and any side effect they perform still happens, after your code has already moved on to the error path.
The table comes from a three-operation reproduction on Node.js 22.23.4. Operation A fails early, B succeeds later, and C writes to a store later still. Timings are approximate. The order of events is the result.
| What you might ask | What the run showed |
|---|---|
| Did the aggregate promise reject? | Yes, at about 50 ms, with the same error object A rejected with |
| Did B, an input promise, still settle? | Yes, it fulfilled at about 150 ms |
| Did C, the underlying operation, still run? | Yes, it wrote to the store at about 250 ms, after the aggregate had rejected |
| Can B's value be read through the aggregate? | No. It is only available from the promise b itself |
| Did anything cancel B or C? | No. Promise.all() has no concept of cancellation |
That is where most explanations stop. The more useful question is what your code should do with work that is still running after a failure. The rest of this article covers how the promise combinators differ, what cooperative cancellation with AbortController can and cannot do, how to choose between the options, and why retrying the whole group after a partial failure can duplicate writes.
Why a rejected Promise.all() does not mean the work stopped
Picture a settings page with a Save all button. It sends three requests at once: profile, notification preferences and billing address.
await Promise.all([
saveProfile(form.profile),
saveNotifications(form.notifications),
saveBillingAddress(form.billing),
]);
The billing request fails fast on validation. Promise.all() rejects, the catch block runs, and the page shows "Save failed, please try again." The user reads that as "nothing was saved." But the other two requests were already on the wire, and nothing told them to stop. Assume both complete a moment later. The user clicks Save again, and the same three requests go out a second time.
Nothing in that sequence is a bug in Promise.all(). It did what it is specified to do. The bug is the assumption that a rejected aggregate means the group of operations has stopped. The error described the aggregate promise, and the user took it to describe the work.
The same gap appears when you upload several files, call several endpoints inside an SDK method, or fan out data transforms. Whenever an operation has an effect outside the promise (a write, a message sent, a file created, a charge made), the distance between "the promise rejected" and "the work stopped" is where duplicate writes, stale screens and confusing logs come from. If the operations are pure reads whose results you discard, the same gap costs you some bandwidth and CPU and may not matter at all. The first job is to know which case you are in.
What Promise.all() actually does with its input promises
Three different things hide behind the word "promise" in this problem, and mixing them up causes most of the confusion.
| Term | What it is | What controls it |
|---|---|---|
| Aggregate promise | The single promise Promise.all() returns |
Settles once, on the first rejection or when every input has fulfilled |
| Input promises | The promises you pass in | Each settles on its own schedule, independent of the aggregate |
| Underlying operations | The real work: a request, a timer, a disk write, a computation | Whatever started it. A promise only reports on it |
A promise is a report on an operation, not the operation. Its state tells you whether the report has come in. It does not tell you whether the work is running, finished or stopped.
The specification shows why Promise.all() cannot reach into the operations. For each element of the iterable, it converts the element to a promise and calls then on it with two functions. The fulfillment handler stores the value at that element's position and counts down the elements still pending. The rejection handler is the reject function of the aggregate promise itself. The algorithm is PerformPromiseAll in the ECMAScript specification. Four consequences follow.
- The first rejection settles the aggregate. The first input to reject calls the aggregate's reject function. Any later call does nothing, because a promise settles only once.
- Later successes are never delivered. The aggregate resolves only when a counter of pending inputs reaches zero, and the counter only goes down when an input fulfills. The input that rejected never counts down, so the counter never reaches zero. The values from the other inputs are collected into a list that nothing can read, and the aggregate's resolve function is never called.
-
Every input counts as handled. Attaching
thenhandlers marks each input promise as handled. MDN states that rejections after the first are ignored and do not triggerunhandledrejectionevents. -
Nothing in the algorithm touches the operations. The only thing
Promise.all()does to an input is attach handlers, by callingthenon the promise it gets fromPromise.resolve(). It has no way to cancel, pause or inspect the work behind it.
One more detail that is easy to miss. In the common pattern Promise.all([a(), b(), c()]), JavaScript calls a, b and c before Promise.all() runs at all. If those functions start their work before they return a promise, which they usually do, that work is already underway when Promise.all() receives the promises. Promise.all() never calls your functions and does not start operations for you. It observes work that is already in motion. The one exception is a lazy thenable, an object whose then method only starts the work when it is called. Promise.all() passes each input through Promise.resolve(), and for a thenable that queues a job which calls its then. I checked this: the work begins a moment after Promise.all() has returned, instead of before. Promises returned by real APIs such as fetch do not behave that way.
MDN also notes that rejecting the aggregate does not unsubscribe the handlers attached to the inputs. Repeatedly passing the same long-lived pending promise to Promise.all() can accumulate handlers on it, even when another input rejects each time. A shared shutdown promise reused inside a loop is one way to run into that.
The smallest reproduction: what happens after Promise.all() rejects
Three timer-driven operations are enough to separate the aggregate, the inputs and the work. A rejects at about 50 ms. B fulfills at about 150 ms with a plain value. C writes to a store at about 250 ms, and the store stands in for anything outside the promise, such as a file, a row or a message. This is the core of 01-lifecycle.mjs, trimmed to the essentials.
import { createTimeline, sleep } from "./lib/timeline.mjs";
const { log } = createTimeline();
const store = []; // stands in for "something outside the promise"
function start(name, ms, behaviour) {
log(`${name} started`);
return new Promise((resolve, reject) => {
setTimeout(() => {
if (behaviour === "reject") {
reject(new Error(`${name} failed`));
} else if (behaviour === "write") {
store.push(name);
log(`${name} side effect performed (store = [${store}])`);
resolve(`${name} result`);
} else {
resolve(`${name} result`);
}
}, ms);
});
}
const a = start("A", 50, "reject");
const b = start("B", 150, "value");
const c = start("C", 250, "write");
for (const [name, p] of [
["A", a],
["B", b],
["C", c],
]) {
p.then(
(v) => log(`${name} promise fulfilled with "${v}"`),
(e) => log(`${name} promise rejected with "${e.message}"`),
);
}
const aggregate = Promise.all([a, b, c]);
aggregate.then(
() => log("AGGREGATE fulfilled"),
(e) => log(`AGGREGATE rejected with "${e.message}"`),
);
The .then observers on a, b and c only record when each input settles. Promise.all() has already attached its own handlers to the same promises. This is the output from a real run on Node.js 22.23.4. The numbers change from run to run, and the order does not.
0 ms A started
5 ms B started
5 ms C started
55 ms A promise rejected with "A failed"
55 ms AGGREGATE rejected with "A failed"
154 ms B promise fulfilled with "B result"
256 ms C side effect performed (store = [C])
256 ms C promise fulfilled with "C result"
After 400 ms the script also checks the end state.
aggregate: rejected (A failed)
store: ["C"]
aggregate rejected with the very same error object as A: true
B value, read from the individual promise b: B result
What this shows. The aggregate settled about 200 ms before the last operation finished. B still fulfilled after the aggregate rejected, and C still performed its side effect after the aggregate rejected. The error the caller received is the object A rejected with, not a copy or a wrapper. B's value is still readable, but only from b, never from the aggregate.
What it does not show. These are simulated operations on timers. The run says nothing about how a particular HTTP client, database driver or file API behaves when its caller stops waiting. It also covers only operations that have no cancellation support. The cancellation case comes later.
What happens after the first rejection
A later success
Already covered by the timeline. The operation finishes, its side effect happens, and its promise fulfills. Its value is out of reach of the aggregate. If your cleanup code needs that value, for example the id of a resource that was just created, you have to keep a reference to the input promise yourself, or collect outcomes with Promise.allSettled() instead.
A later rejection, and whether it becomes an unhandled rejection in Node.js
It is easy to assume that once Promise.all() has rejected, the other failures have nobody listening and surface as unhandled rejections. The language-level answer is no, because Promise.all() attached a rejection handler to every input. The second script checks that on Node.js, where unhandled rejection reporting is a runtime feature and not part of the language. Three inputs reject at about 20, 60 and 90 ms, all passed to Promise.all().
Scenario 1: P1, P2, P3 reject at 20/60/90 ms; all passed to Promise.all
22 ms aggregate rejected with "P1"
unhandledRejection events in scenario 1: 0
Outcomes read from the individual promises afterwards:
P1: rejected (P1)
P2: rejected (P2)
P3: rejected (P3)
Zero unhandledRejection events, even though two inputs rejected after the aggregate. The control case is a single rejecting promise that is not passed to anything.
Scenario 2 (control): one rejecting promise that is NOT passed to anything
273 ms process 'unhandledRejection' event for "LONE"
unhandledRejection events in scenario 2: 1
Node's process documentation says the unhandledRejection event fires when a promise is rejected and no handler is attached within one turn of the event loop. The command-line documentation says the default --unhandled-rejections mode is throw. With no unhandledRejection listener installed, the rejection is raised as an uncaught exception. The script checks that default in two child processes with no listener.
one rejection nobody handles -> exit code 1
Promise.all, a later input rejects -> exit code 0
The second child attaches a .catch() to the aggregate. That matters, because the aggregate is a promise too. If you call Promise.all() and never await it or attach a handler, the aggregate's own rejection is the unhandled one. Siblings are not.
So when an unhandled rejection does show up around a Promise.all() call, a likely cause is a promise that was never an input. That could be a timeout promise created on the side, a helper that returns a rejecting promise nobody awaited, or an aggregate nobody handled.
Multiple failures
The aggregate carries one rejection reason, the first one it observed. The other failures are not lost, since each input promise keeps its own state, and the script reads all three back with Promise.allSettled() after the fact. But that only works if you still hold the input promises. If the application needs every error, for logging, reporting or deciding what to clean up, collecting outcomes up front is simpler than reconstructing them later. Here are the same three failures, collected with Promise.allSettled().
Scenario 3: same shape of failures, collected with Promise.allSettled
Q1: rejected (Q1)
Q2: rejected (Q2)
Q3: rejected (Q3)
Promise.all() vs Promise.allSettled() vs Promise.any() vs sequential await
The third script runs the same three operations under each strategy. A rejects at about 50 ms. B and C succeed at about 150 and 250 ms, and each writes to the store. For every strategy it records when the aggregate settled and which writes landed after that moment. Same Node.js 22.23.4 run.
| Strategy | Settles at | Outcome | Writes before it settled | Writes after it settled |
|---|---|---|---|---|
Promise.all() |
about 50 ms | Rejects with A's error | none | B, C |
Promise.allSettled() |
about 250 ms | Fulfills with rejected, fulfilled, fulfilled | B, C | none |
Promise.any() |
about 150 ms | Fulfills with B's value | B | C |
Sequential await
|
about 50 ms | Rejects with A's error | none | none, because B and C never started |
None of the three combinators cancels anything. They differ in when they settle and in what they let you see.
Promise.all() settles as soon as any input rejects, or when all have fulfilled. It exposes the first failure and, on success, every value in input order. It is useful when you need one aggregate result that fulfills only if every input fulfills, and fail-fast rejection is appropriate. It does not give you rollback or atomicity. If the operations have side effects, you still need to decide what happens to the work that remains in progress.
Promise.allSettled() waits for every input to settle and never rejects because an input did. It fulfills with one object per input, in input order, each with a status of fulfilled or rejected and either a value or a reason. The price is time. It settled at about 250 ms in the run above, because it waited for the slowest operation even though A failed at 50 ms. Use it when every outcome matters, and not when you want to fail fast.
Promise.any() settles on the first fulfillment. It rejects only when every input rejects, with an AggregateError whose errors array lists the reasons in input order, not in the order they happened. The script confirms that with a slow first input and a fast second one.
Promise.any AggregateError.errors order: [ 'first input (slow)', 'second input (fast)' ]
MDN documents that fulfilling the result does not cancel the remaining operations, that every input is marked as handled, and that an empty iterable produces an already rejected promise. In the table, C still wrote at about 250 ms after Promise.any() had settled with B's value.
Sequential await is not a combinator, but it is the other way to avoid the problem. It stops later operations from ever starting, so after A fails there is no B or C to worry about. This only holds when you start each operation after the previous await has finished. If you create all the promises first and then await them one by one, everything has already started. Sequential execution does not make failure safer in general. Anything that already ran stays done, and the total time becomes the sum of the operation times instead of the longest one. It is the right choice when operations depend on each other, or when running them together would be wrong for the target system. It is the wrong choice when they are independent and the user is waiting.
How to cancel the remaining requests when Promise.all() fails
Promises have no cancellation mechanism of their own. The platform's answer is cooperative cancellation with AbortController. You create a controller, hand its signal to the operations, and call abort() when you want them to stop. The signal only communicates. As the MDN page for AbortSignal puts it for promise-based APIs, an operation should react to the abort by rejecting its unsettled promise with the signal's reason. Whether it does is up to the operation.
There is a design consequence. In the common pattern, the functions that produce the promises have already been called before Promise.all() receives them, so there is no moment at which you could give them a signal. A cancellation-aware version has to take functions that start the work and accept a signal. That also lets it skip operations that have not started yet when something has already failed.
The repository contains a teaching implementation, runAllOrAbort. Validation and comments are trimmed here, and the full file is in the repo.
export class SiblingFailedError extends Error {
constructor(cause) {
super("Aborted because another task in the group failed", { cause });
this.name = "SiblingFailedError";
}
}
export class GroupFailedError extends Error {
constructor(cause, { outcomes, timedOut, whenSettled }) {
super("A task in the group failed", { cause });
this.name = "GroupFailedError";
this.outcomes = outcomes;
this.timedOut = timedOut;
this.whenSettled = whenSettled;
}
}
const isAbortInduced = (reason, signal) =>
signal.aborted &&
(reason === signal.reason || reason?.cause === signal.reason);
export async function runAllOrAbort(tasks, { settleTimeoutMs = 5000 } = {}) {
const controller = new AbortController();
const { signal } = controller;
const outcomes = tasks.map(() => ({ status: "not-started" }));
const snapshot = () => outcomes.map((o) => ({ ...o }));
let firstFailure = null;
let markFailed;
const failed = new Promise((resolve) => {
markFailed = resolve;
});
const running = tasks.map(async (task, i) => {
if (signal.aborted) return;
outcomes[i] = { status: "pending" };
try {
const value = await task(signal);
outcomes[i] = {
status: "fulfilled",
value,
observedAfterAbort: signal.aborted,
};
} catch (reason) {
const abortInduced = isAbortInduced(reason, signal);
outcomes[i] = {
status: "rejected",
reason,
abortInduced,
observedAfterAbort: signal.aborted,
};
if (!abortInduced && !firstFailure) {
firstFailure = { reason };
controller.abort(new SiblingFailedError(reason));
markFailed();
}
}
});
const allSettled = Promise.all(running).then(() => "settled");
await Promise.race([allSettled, failed]);
if (!firstFailure) return outcomes.map((o) => o.value);
let timer;
const deadline = new Promise((resolve) => {
timer = setTimeout(resolve, settleTimeoutMs, "timeout");
});
const result = await Promise.race([allSettled, deadline]);
clearTimeout(timer);
throw new GroupFailedError(firstFailure.reason, {
outcomes: snapshot(),
timedOut: result === "timeout",
whenSettled: allSettled.then(snapshot),
});
}
The design choices worth noticing.
-
The first genuine failure triggers the abort. The abort reason is a
SiblingFailedErrorthat wraps the original error, so a task can tell why it was stopped. -
A failure is separated from an abort. A rejection counts as abort-induced only when it is the abort reason itself or carries it as its
cause. The nameAbortErroralone is not evidence, because a task can fail on its own with an error of that name. -
The caller gets the original error.
GroupFailedError.causeis the first genuine failure, along with a snapshot of every task's status. -
Waiting is bounded. After aborting, the helper waits up to
settleTimeoutMsfor started tasks to settle, then throws anyway.whenSettledresolves later with the final outcomes. -
Promise.all()is safe inside it. Every entry inrunningcatches its own errors, so that aggregate never rejects.
The fourth script drives it with five tasks. E finishes before the failure. A fails at about 50 ms. B uses Node's timers/promises setTimeout with a signal, an API that supports abort. C is a hand-written timer that honours the signal. D ignores the signal entirely. Logging is trimmed here.
const tasks = [
// E: finishes before the failure
async () => {
await sleep(20);
effect("E");
return "E ok";
},
// A: the genuine failure
async () => {
await sleep(50);
throw new Error("A failed");
},
// B: a real API that supports AbortSignal
async (signal) => {
const v = await timersSleep(150, "B ok", { signal });
effect("B");
return v;
},
// C: hand-written, honours the signal
(signal) =>
new Promise((resolve, reject) => {
if (signal.aborted) return reject(signal.reason);
const t = setTimeout(() => {
effect("C");
resolve("C ok");
}, 250);
signal.addEventListener(
"abort",
() => {
clearTimeout(t);
reject(signal.reason);
},
{ once: true },
);
}),
// D: ignores the signal entirely
async () => {
await sleep(250);
effect("D");
return "D ok";
},
];
Task C is deliberately minimal. In real code, also remove the abort listener when the timer completes normally, as MDN advises for abortable APIs, so a long-lived signal does not keep it alive. { once: true } only removes the listener if the signal actually aborts.
First run, waiting for every task to settle after the abort (Node.js 22.23.4). The script also prints status tables, which are shown separately below.
21 ms E side effect performed
51 ms A throws
52 ms C received abort and cleared its timer
52 ms B rejected: name=AbortError, cause is signal.reason: true
251 ms D side effect performed
252 ms runAllOrAbort threw: cause="A failed"
Side effects that happened in total: E, D
| Task | Status when the helper threw | Abort-induced | Observed after abort |
|---|---|---|---|
| E | fulfilled | - | false |
| A | rejected | false | false |
| B | rejected | true | true |
| C | rejected | true | true |
| D | fulfilled | - | true |
A dash means the field does not apply. Four different things happened, and they are easy to blur together.
- E finished before anyone asked for cancellation. The abort could not undo it, and its side effect stands.
- B and C received the signal and stopped. Their 150 ms and 250 ms timers never produced a side effect. This is cancellation working, because each of them cooperates.
- D received the signal too, because it was passed to every task, and ignored it. It ran to completion and wrote at about 251 ms. The abort request did not stop it.
-
observedAfterAbortis true for D because the helper saw D settle after the abort had been requested. It records what the helper saw, not when the work physically finished.
The second run uses a settleTimeoutMs of 100. The helper aborts, waits 100 ms and then throws without waiting for D. These are the log lines in time order.
21 ms E side effect performed
50 ms A throws
51 ms C received abort and cleared its timer
51 ms B rejected: name=AbortError, cause is signal.reason: true
152 ms runAllOrAbort threw: cause="A failed"
251 ms D side effect performed
Side effects that happened in total: E, D
When the error reached the caller, D's status was pending. That is all the caller could know. Then D wrote at about 250 ms anyway, after the error had been reported. This is the difference between three things that people often treat as one. Stopping the caller's wait (the helper threw), requesting cancellation (the abort), and actually terminating the work (something only the operation itself can do).
One note on the output above. The line B rejected: name=AbortError, cause is signal.reason: true is an observation from Node.js 22.23.4. Node's timers documentation says the promise returned by the timer rejects with an AbortError when its signal aborts, and the section does not mention a cause. The helper accepts both shapes (rejecting with signal.reason itself, or an error that carries it as cause) so it does not depend on one runtime detail. If you adapt it, test it against the APIs you actually call. fetch was not tested here.
Which approach to choose: a decision table
There is no single right answer, because the right answer depends on what the operations do and what a failure means. These five questions get you most of the way. Do you need every result? Do you need to hear about every failure? Do the operations have effects outside the promise? Do they support cancellation? Does one depend on another?
| Situation | Use | What you accept |
|---|---|---|
| All operations must succeed, and you do not need to stop the others automatically | Promise.all() |
The others keep running after a failure. Fine for cheap or idempotent work, risky for expensive or side-effecting work. No rollback |
| Every outcome matters, because you report, retry or clean up per item | Promise.allSettled() |
You wait for the slowest operation. No fail-fast |
| The first success is enough, such as mirrors or fallbacks | Promise.any() |
The losers keep running unless you cancel them. If all fail, you get an AggregateError with reasons in input order |
| Remaining work is expensive or useless after a failure, and the operations accept a signal | Concurrent tasks with a shared AbortController
|
Tasks that ignore the signal keep running, finished work is not undone, and you need to treat abort errors differently from real ones |
| Operations depend on each other, or concurrency would be wrong for the target | Sequential await
|
Total time is the sum of the parts. Earlier completed steps still stand if a later one fails |
Three rules of thumb sit under the table.
Let the rest finish when the work is independent, cheap and still useful. Prefetching three panels of a dashboard is a good example. If one panel fails, the other two results are worth having, and Promise.allSettled() lets you render them.
Request cancellation when the remaining work is expensive or its result is now pointless. Large downloads, heavy computations and long-polling requests are the classic cases. Do it only through APIs that accept a signal, and do not describe it to yourself as stopped until the operation says so.
When cancellation is unsupported, change what you promise. You cannot abort arbitrary JavaScript work from outside it. What you can do is stop waiting, ignore the late result, and make the effect safe to repeat. Mark the operation's state in your own code (for example, a request id that the UI checks before it renders a late response), and use idempotency keys for writes, as the next section shows.
Retrying after Promise.all() fails: why repeating the whole group can duplicate writes
Back to the Save all button. The fifth script reproduces it without a database or a network. An in-memory ledger stands in for any destination where a write has an effect. Three writes run together, and w3 fails once.
function createLedger() {
const rows = [];
const seen = new Set();
let failNextFor = new Set();
return {
rows,
failOnce: (id) => failNextFor.add(id),
// Without an idempotency key, every call appends a row.
async write(id, key) {
await sleep(id === "w3" ? 20 : 60);
if (failNextFor.has(id)) {
failNextFor.delete(id);
throw new Error(`${id} failed`);
}
if (key && seen.has(key)) return `${id} (duplicate ignored)`;
if (key) seen.add(key);
rows.push(id);
return id;
},
};
}
The first scenario is what the button in the opening example does. It runs Promise.all() on the three writes, and on failure runs the whole group again.
attempt 1 rejected: w3 failed
ledger after attempt 1: [ 'w1', 'w2' ]
ledger after naive full retry: [ 'w1', 'w2', 'w3', 'w1', 'w2' ]
w3 failed first, so the aggregate rejected at about 20 ms. w1 and w2 were already in flight and landed afterwards, which is why the ledger holds them after attempt 1 even though the attempt "failed". The retry then wrote all three again. w1 and w2 now exist twice.
Two other approaches fix it, and they work differently. Giving each write a stable key derived from the logical operation, not from the attempt, lets the receiver drop repeats. Collecting outcomes with Promise.allSettled() lets you retry only what failed.
// Scenario 2: retry the whole group, but every write carries a stable key
const attempt = () =>
Promise.all(ids.map((id) => ledger.write(id, `order-42:${id}`)));
// Scenario 3: collect outcomes, retry only the failures
const first = await Promise.allSettled(ids.map((id) => ledger.write(id)));
const failedIds = ids.filter((_, i) => first[i].status === "rejected");
await Promise.all(failedIds.map((id) => ledger.write(id)));
ledger after full retry with idempotency keys: [ 'w1', 'w2', 'w3' ]
failed after first pass: [ 'w3' ]
ledger after retrying only failures: [ 'w1', 'w2', 'w3' ]
Both end with each write exactly once. The third approach has a trap, though, which is the fourth scenario. A rejected call does not prove the write failed. A timeout can reject the caller while the write still commits on the other side. The script injects that case. The write commits, but the caller only sees a timeout.
Simulated lost response, the write commits but the caller sees a timeout
attempt 1 rejected (w1 timed out waiting for the response)
after a retry without an idempotency key, ledger = [ 'w1', 'w1' ]
attempt 1 rejected (w1 timed out waiting for the response)
after a retry with an idempotency key, ledger = [ 'w1' ]
That last scenario is simulated, not measured. It shows the logic, not how often a real system loses a response.
What to take from it.
- Retrying only the failures is safe only when a failure is known to mean "not applied." A validation error usually does. A timeout does not.
-
Idempotency keys are the safety net, and the receiving service has to enforce them. The
Setin the script lives inside one process. Real enforcement has to survive restarts, multiple servers and concurrent requests, so it belongs in the service or database that accepts the write. -
Do not start a retry while attempt one is still in flight. After a failure, work from the first attempt may still be landing, as
w1andw2did. If you request cancellation, wait forwhenSettled(or accept the risk knowingly) before retrying. The overlap case was not run as a separate script. It follows directly from the lifecycle shown earlier. - A cancelled operation may still have committed. Aborting after the request has been sent does not tell you whether the other side applied it.
Limitations
Some of what I wrote here is promised by the JavaScript spec, so it holds in every engine. The rest is just what I saw when I ran the scripts in Node.js, and another runtime could do something different. Here is how I split the two.
-
From the specification and MDN. These come from the documentation, not from my runs. The aggregate settles on the first rejection, nothing gets cancelled, handlers are attached to every input,
Promise.any()listsAggregateErrorreasons in input order, and promise-based APIs are expected to reject with the abort reason. -
Observed on Node.js only. I only saw these on Node.js, namely the
unhandledRejectioncounts, the exit codes, and thecauseon theAbortErrorfromtimers/promises. They are runtime behaviour, so they could differ elsewhere. I ran the scripts on Linux with Node.js 22.23.4, 24.22.2 and 26.13.0. The tests and all five scripts also passed on Windows with Node.js 22.22.2, and that run printed the same events in the same order, line for line, once I ignored the elapsed milliseconds. Windows was slower. I did not test any other versions or operating systems, and the output blocks in this article come from one Linux run on 22.23.4. -
I did not test browsers. MDN documents that
Promise.all()marks its inputs as handled and that later rejections do not triggerunhandledrejection. Treat that as MDN's claim, since my runs did not verify it. -
Only one real cancellable API got tested. That was the timer from
timers/promises, plus a hand-written task. I did not testfetch, streams, file APIs or database drivers, so please do not assume they behave like the timer. I did test what an aborted request does to a node-postgres query in an earlier post. -
I did not look at
Promise.race(). It has the same shape of problem, with its own questions about timers and cleanup. - The operations are simulated with timers. The timings are approximate, and the order of events is what I am claiming. I ran each script ten times and compared the full output, ignoring the elapsed milliseconds. Every script produced one distinct output, on Linux and also on Windows with Node.js 22.22.2.
-
runAllOrAbortis there to teach the pattern, not to replace a concurrency library. It cannot stop a task that ignores the signal, it cannot undo work that already finished, andwhenSettledstays pending if a started task never settles. The failure it reports is the first one it saw, which is not always the one that physically happened first.settleTimeoutMshas to be an integer from 1 to 2147483647. Anything else throws aRangeError, becausesetTimeout()normalizes out-of-range delays to 1 ms. - The ledger in the retry script lives in memory. Real idempotency has to be enforced by the service that receives the write. The lost response in the last scenario is injected, not measured.
Reproduce the examples
Everything above comes from the promise-all-rejection-and-cancellation repository. It is plain Node.js with no dependencies, no network access, no servers and no database. It needs Node.js 22 or newer.
git clone https://github.com/shubhrapokhariya/promise-all-rejection-and-cancellation.git
cd promise-all-rejection-and-cancellation
npm run verify # helper tests, then all five scripts
npm run repeat # run each script 10 times and compare the full output, takes about a minute
npm test # helper tests only
node 01-lifecycle.mjs # or any single script, 01 to 05
| File | What it shows |
|---|---|
01-lifecycle.mjs |
The aggregate rejects first, the other inputs still settle, and a side effect still happens afterwards |
02-later-rejections.mjs |
Later input rejections raise no unhandledRejection event in Node, and a rejection nobody handles does |
03-combinators.mjs |
all, allSettled, any and sequential await on the same operations |
04-cancellation.mjs |
A shared AbortController, tasks that honour the signal, one that ignores it, and giving up the wait |
05-partial-failure.mjs |
Full retry versus idempotency keys versus retrying only failures, plus a simulated lost response |
lib/run-all.mjs |
The runAllOrAbort helper, covered by 19 tests in test/run-all.test.mjs
|
Each line of a timeline starts with elapsed milliseconds. Treat the order of events as the result, since the exact numbers change from run to run. The repository also includes a GitHub Actions workflow that runs npm run verify on Node.js 22, 24 and 26.
Practical takeaway
A rejected Promise.all() ends your wait. It does not end the work. Four facts that look like one are actually separate. The promise rejected, cancellation was requested, the operation stopped, and the effect happened. Your code can observe the first two directly. The last two are known only if the operation tells you.
Before wrapping side-effecting work in Promise.all(), ask three questions. Which of these operations already has an effect outside the promise? Does each one accept a signal and actually honour it? Is the effect safe to repeat if the whole thing runs again? If you cannot answer the third question with yes, fix that first. Cancellation reduces waste. Idempotency prevents duplicate writes.
The only real cancellable API I tested here was Node's timer, and I covered database drivers in an earlier post, so I am curious about everything else. Has an HTTP client, SDK or queue ever kept working after its AbortSignal fired on you? Tell me which one and what you saw in the comments.
Sources
Official documentation and specification pages, checked on 10 November 2026.
- ECMAScript specification, PerformPromiseAll
- MDN, Promise.all()
- MDN, Promise.allSettled()
- MDN, Promise.any()
- MDN, AbortSignal
- MDN, AbortController.abort()
- Node.js, process, the unhandledRejection event
- Node.js, command-line options, --unhandled-rejections
- Node.js, timers, cancelling timers with AbortController
Top comments (0)