Skip to content

Promise.all failures: stop admission, then drain work

Separate the first failed result from admission, cooperative cancellation and final settlement; preserve input-indexed outcomes and wait before releasing shared resources.

Dopstack Engineering

· 9 min read

Original conceptual illustration: three worker lanes show a failed item, a cooperative abort and a final drain behind a closed admission boundary.

Quick answer

`Promise.all` rejects when an input rejects, but it does not cancel the other operations. For a batch that must stop after failure, control when work starts, request cancellation through APIs that support it, and wait for started work to settle before releasing shared resources. Replacing `all` with `allSettled` changes result collection; it does not create a concurrency limit or cancellation policy.

Promise.all rejects when an input rejects, but it does not cancel the other operations. For a batch that must stop after failure, control when work starts, request cancellation through APIs that support it, and wait for started work to settle before releasing shared resources. Replacing all with allSettled changes result collection; it does not create a concurrency limit or cancellation policy. [1] [2]

Scenario: a catalog service refreshes product summaries through read-only HTTP requests. It starts a batch, one request fails, and the handler reports an error. Other requests are still using the batch's shared resources. A cleanup block now closes those resources while the requests continue. The first error was visible; the lifecycle was unfinished.

We will build a small bounded worker helper for this case. Its contract is intentionally specific: after observing a failure, admit no further items, request cooperative cancellation, retain outcomes by input position, and settle every worker before returning. This is an original example implementation, not a general transaction system or a promise-library recommendation.

Why does work continue after catch?#

Calling an asynchronous function often starts its operation before Promise.all receives the resulting promise. An eager items.map(load) can therefore start the entire input set before aggregation begins. The aggregate reports results; it does not own the underlying network requests. MDN explicitly documents that aggregate rejection does not cancel them. [1]

A delayed operation beside a rejected promise makes the ordering visible. The delay below is illustrative; inspect the ordering rather than treating it as a performance measurement.

js
const events = [];
const slow = new Promise(resolve => {
  setTimeout(() => { events.push('slow finished'); resolve(); }, 20);
});
try {
  await Promise.all([slow, Promise.reject(new Error('failed'))]);
} catch {
  events.push('caught');
}
await slow;
console.log(events); // ['caught', 'slow finished']

Decide which item outcomes the caller needs to retain. Promise.allSettled retains the outcome of each input, including fulfilled values and rejection reasons. It is useful for collecting completed work, but passing an already-started array to it does not defer any of that work. [2]

Avoid a related diagnostic mistake: a later rejection among promises already passed to Promise.all is handled by the aggregate's attached handlers. Do not assume every later failure produces an unhandled-rejection event. Record outcomes deliberately when they matter to the batch investigation. [1]

Give the batch one lifecycle#

Separate admission, execution, cancellation and draining. Admission chooses which item may start. Execution owns the actual operation. Cancellation asks an operation to stop. Draining waits until every started worker has finished, whether normally or after a cancellation request.

For the catalog case, a failed item invalidates the refresh as a whole. That does not mean the remaining requests disappear. A shared AbortController can notify cooperating operations, while a stopped flag prevents new items from entering the worker pool. Node's AbortSignal documentation describes notification through the controller; the operation still needs to use that signal. [3]

Close the batch resource after the helper settles. This rule trades faster error return for a known resource lifetime. If the caller must return an HTTP response earlier, a supervised background owner must take responsibility for the remaining work and eventual cleanup.

Lifecycle pointRequired evidenceCommon mistake
AdmissionWhich input indices startedCreating every promise before limiting work
First observed failureOriginal error and input identityReporting only a later cancellation error
Cancellation requestSignal reaches the underlying APIPassing a signal that the API ignores
Drain completeEvery started worker settledClosing resources in the first catch block
Scenario decision and verification criteria

These are our design criteria for the example. They are not a promise that cancellation reverses effects. A server may have received a request before the client aborts, and a completed write does not become undone because another request failed.

How can you bound admission and drain started work?#

Start a fixed number of workers over lazy operations. Each worker claims an index only while admission remains open, then invokes the supplied operation. The helper catches item errors internally so the outer worker aggregate can wait for all workers to finish. Results remain aligned with the original inputs.

js
export async function stopAndDrain(items, concurrency, run) {
  if (!Number.isInteger(concurrency) || concurrency < 1) {
    throw new RangeError('concurrency must be a positive integer');
  }
  const controller = new AbortController();
  const outcomes = items.map(() => ({ status: 'not-started' }));
  let next = 0;
  let stopped = false;
  let firstError;

  async function worker() {
    while (!stopped && next < items.length) {
      const index = next++;
      try {
        const value = await run(items[index], {
          signal: controller.signal, index
        });
        outcomes[index] = { status: 'fulfilled', value };
      } catch (reason) {
        outcomes[index] = { status: 'rejected', reason };
        if (!stopped) {
          stopped = true;
          firstError = reason;
          controller.abort(reason);
        }
      }
    }
  }

  const workers = Array.from(
    { length: Math.min(concurrency, items.length) }, worker
  );
  await Promise.all(workers);
  return { ok: !stopped, firstError, outcomes };
}

The stopped flag is separate from firstError because JavaScript permits rejecting with any value, including undefined. Testing only whether an error variable is truthy would miss that failure. The index is claimed before awaiting, so each worker owns its input position for the duration of that operation.

Admission stops when a failure is observed by the helper. Work may have started before that observation, even if the remote failure happened earlier. A caller should not interpret the boundary as a synchronized stop across servers. It is a local scheduling guarantee with a precise point in the helper's execution.

The implementation retains an outcome slot for each item, so it uses memory proportional to the input length. For a very large or unbounded source, use an iterator and a durable outcome sink with its own backpressure. Do not copy this array implementation into an unlimited ingestion stream without changing that contract.

Keep the input array stable until the batch settles. The worker reads an item when its index is admitted, so mutating or replacing later entries would change the work the batch performs. If callers need snapshot semantics, create an appropriate immutable projection before calling the helper. A shallow array copy alone does not freeze mutable objects inside it.

The helper also has no caller-supplied cancellation signal. An HTTP client disconnect, application shutdown or operator stop therefore needs an explicit integration design. Decide whether that event closes admission, requests abort, changes the reported result, or transfers ownership to a supervisor. Do not assume that a request handler going out of scope cancels its asynchronous work.

What happens when cancellation is ignored?#

The helper keeps waiting for the started operation. This is deliberate: returning while an ignored operation still uses a resource would violate its drain contract. Cancellation is cooperative, and code that does not observe the signal can continue indefinitely. [3]

A fetch adapter must pass the signal and treat unsuccessful HTTP status as an item failure. Fetch can resolve normally for an HTTP error response; checking response.ok establishes the application's chosen status policy. Reading the body is also part of the operation's lifetime. [4]

js
async function readCatalogItem(url, { signal }) {
  const response = await fetch(url, { signal });
  if (!response.ok) {
    await response.body?.cancel();
    throw new Error(`catalog HTTP ${response.status}`);
  }
  return await response.json();
}

const batch = await stopAndDrain(urls, 3, readCatalogItem);
if (!batch.ok) {
  // Persist indexed outcomes, then report the batch failure.
  throw batch.firstError;
}

Scenario: one worker rejects immediately, another observes the abort signal, and another ignores it until its own bounded operation finishes. The helper records all started outcomes and returns only after that last worker settles. Inputs that never entered a worker remain explicitly not started; they are not failures and should not be counted as attempted requests.

Give the underlying operation an appropriate timeout or isolation boundary as well. A timeout that merely wins a Promise.race does not stop its losing operation. For APIs that cannot be interrupted and may never settle, a worker process or another supervised execution boundary may be necessary. The trade-off resembles the resource-lifetime issue in asynchronous thread timeouts.

Choose the result policy before using the helper#

This helper suits a batch whose useful result requires all admitted reads to succeed. It is less suitable when each item is independently valuable and the right behavior is to process the full input despite failures. In that case, keep admission open and return a complete outcome report. The choice is a product rule, not a property inferred from the word parallel.

It also does not create atomic writes. If a request changes remote state before failing, the client may have an uncertain outcome. Retrying that item needs a durable action identity and reconciliation policy; see idempotency for agent retries for the underlying action-identity problem. A worker pool cannot supply a distributed transaction.

If a library already owns concurrency, examine its queue semantics before layering another scheduler over it. The p-limit documentation distinguishes active from pending operations and states that clearing the queue does not cancel running promises. Current options also address how discarded pending work settles. Verify the installed library version rather than assuming all queue-clearing implementations behave alike. [6]

How should the lifecycle be tested?#

Use deterministic gates so the test controls when each operation starts and finishes. Assert the maximum active count, the identity of admitted inputs, the original failure, and the moment at which cleanup is allowed. A test that merely checks the returned error cannot establish that the other work stopped or drained.

Include a synchronous throw from run, an asynchronous rejection, an empty input, invalid concurrency, a successful out-of-order completion, and a rejection whose reason is undefined. Add a worker that cooperates with abort and one that deliberately ignores it until released by the test. The latter exposes accidental early return without depending on network timing.

Scenario: admit A and B, hold B behind a test-controlled gate, then fail A. Assert that the shared signal becomes aborted and C has not entered run. Also assert that the batch promise remains unsettled. Release B and verify that its real result is retained before the helper returns the original A error. The test establishes admission and draining separately; a single returned-error assertion would miss either defect.

Test resource ownership with a sentinel that records accesses after closure. Open it before the helper, make the ignored-cancellation worker access it immediately before settlement, and close it only after the helper returns. The sentinel should show no late access. This is an application-lifetime assertion, not a claim about sockets or remote requests that the synthetic operation never used.

When logging the outcome report, serialize errors deliberately. An Error object does not automatically become a useful structured incident record. Keep the input identity and safe diagnostic fields, and avoid storing credentials or complete response bodies merely because a request failed. The outcome array is an accounting mechanism; the caller still owns its retention and disclosure policy.

Node's promise-based timers can accept an AbortSignal and reject when canceled, which makes them useful for a cooperative example. They do not prove that a database client, native addon or remote service honors cancellation. Test the actual adapter separately. [5]

The example checks for this article use synthetic operations and locally controlled promises. No external catalog service, production load test or cancellation benchmark is claimed. Keep that distinction in the handoff: a correct scheduler can still be paired with an operation whose timeout, connection handling or remote-effect semantics violate the intended application contract.

Does Promise.all cancel other promises after rejection?
No. The aggregate rejects, while the underlying operations continue unless their own APIs support and receive cancellation. If those operations use shared resources, keep their lifetime separate from the moment the aggregate reports its first error. [1]
Is Promise.allSettled a concurrency limit?
It collects the final outcome of every input promise. It does not prevent an eager map from starting the entire batch. Bound concurrency by delaying operation invocation until a worker or limiter admits each input. [2] [6]
Can abort undo a completed request?
An abort signal requests cooperation from an operation; it is not a transaction rollback. A remote action may already have happened. For writes, define idempotency and reconciliation separately, and avoid treating client cancellation as proof of server-side non-execution. [3] [4]
What if an operation never settles after abort?
The stop-and-drain helper remains pending because its contract waits for started work. Add an effective timeout or supervised isolation boundary to the underlying operation. Returning early without a new owner leaves the original resource-lifetime problem unresolved. [3]

Technologies

  • JavaScript
  • Node.js
  • AbortController

Sources

  1. [1]
    Promise.all, MDN

    Primary source read during research 2 November 2026; review continued after Amsterdam midnight. Living documentation unless dated in ledger; examples illustrative. Exact version and limits discussed in article.

  2. [2]
    Promise.allSettled, MDN

    Primary source read during research 2 November 2026; review continued after Amsterdam midnight. Living documentation unless dated in ledger; examples illustrative. Exact version and limits discussed in article.

  3. [3]
    Global objects: AbortController, Node.js

    Primary source read during research 2 November 2026; review continued after Amsterdam midnight. Living documentation unless dated in ledger; examples illustrative. Exact version and limits discussed in article.

  4. [4]
    Using the Fetch API, MDN

    Primary source read during research 2 November 2026; review continued after Amsterdam midnight. Living documentation unless dated in ledger; examples illustrative. Exact version and limits discussed in article.

  5. [5]
    Timers, Node.js

    Primary source read during research 2 November 2026; review continued after Amsterdam midnight. Living documentation unless dated in ledger; examples illustrative. Exact version and limits discussed in article.

  6. [6]
    p-limit, sindresorhus/p-limit

    Primary source read during research 2 November 2026; review continued after Amsterdam midnight. Living documentation unless dated in ledger; examples illustrative. Exact version and limits discussed in article.

Written by

Dopstack Engineering

Engineering team

The engineering team at Dopstack. Illustrative scenarios are composites written to explain a pattern, not accounts of client work.

All articles Website

Get new articles by email

Engineering notes on AI, cloud and SaaS. Occasional, never sold or shared.

Send me new Dopstack Insights articles by email. I can unsubscribe at any time. We’ll email you once to confirm. Privacy

Continue reading