DEV Community

Cover image for Does Promise.all() Cancel Other Promises When One Rejects?
Shubhra Pokhariya
Shubhra Pokhariya

Posted on Originally published at shubhra.dev AI-assisted

Does Promise.all() Cancel Other Promises When One Rejects?

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),
]);
Enter fullscreen mode Exit fullscreen mode

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}"`),
);
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)' ]
Enter fullscreen mode Exit fullscreen mode

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),
  });
}
Enter fullscreen mode Exit fullscreen mode

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";
  },
];
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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;
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

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' ]
Enter fullscreen mode Exit fullscreen mode

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)));
Enter fullscreen mode Exit fullscreen mode
ledger after full retry with idempotency keys: [ 'w1', 'w2', 'w3' ]
failed after first pass: [ 'w3' ]
ledger after retrying only failures: [ 'w1', 'w2', 'w3' ]
Enter fullscreen mode Exit fullscreen mode

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' ]
Enter fullscreen mode Exit fullscreen mode

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 Set in 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 w1 and w2 did. If you request cancellation, wait for whenSettled (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() lists AggregateError reasons 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 unhandledRejection counts, the exit codes, and the cause on the AbortError from timers/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 trigger unhandledrejection. 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 test fetch, 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.
  • runAllOrAbort is 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, and whenSettled stays 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. settleTimeoutMs has to be an integer from 1 to 2147483647. Anything else throws a RangeError, because setTimeout() 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
Enter fullscreen mode Exit fullscreen mode
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.

Top comments (0)