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.

  1. 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.
  2. 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.
  3. Every input counts as handled. Attaching then handlers marks each input promise as handled. MDN states that rejections after the first are ignored and do not trigger unhandledrejection events.
  4. Nothing in the algorithm touches the operations. The only thing Promise.all() does to an input is attach handlers, by calling then on the promise it gets from Promise.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 SiblingFailedError that 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 name AbortError alone is not evidence, because a task can fail on its own with an error of that name.
  • The caller gets the original error. GroupFailedError.cause is the first genuine failure, along with a snapshot of every task's status.
  • Waiting is bounded. After aborting, the helper waits up to settleTimeoutMs for started tasks to settle, then throws anyway. whenSettled resolves later with the final outcomes.
  • Promise.all() is safe inside it. Every entry in running catches 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.
  • observedAfterAbort is 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. Thi...