~/blog

Promise.all rejects fast, but the other promises keep running

published

#javascript#async#promises

TL;DR

Promise.all settles as soon as one input rejects, but it has no power to stop the others. They keep running, finish after your catch block has already returned, and their side effects land anyway. If a second one also rejects, that error is discarded silently — no unhandledRejection, no log. Cancellation is your job: thread an AbortSignal through and abort it yourself.

The problem

You fire three writes together, one fails, you catch it and return an error. You assume nothing happened. Two of the three writes happened.

const task = (ms, label) =>
  new Promise((resolve) =>
    setTimeout(() => {
      console.log(`${label} finished`);
      resolve(label);
    }, ms),
  );

try {
  await Promise.all([
    task(100, 'A'),
    Promise.reject(new Error('B failed')),
    task(300, 'C'),
  ]);
} catch (err) {
  console.log('caught:', err.message);
}
console.log('handler returned, await is over');

On Node v26.2.0 that prints:

caught: B failed
handler returned, await is over
A finished
C finished

Read the order carefully. handler returned comes second, and A and C report in afterwards. By the time you were writing your error response, those two tasks were still in flight — and they completed normally, 100 ms and 300 ms later, long after the request you were serving had moved on.

Why it happens

A promise is not a task. It is a handle on a result that is already being computed. By the time Promise.all receives the array, every one of those operations has already started — task(100, 'A') ran the moment the array literal was evaluated, before Promise.all was even called. There is no handle to pull, because JavaScript promises have no cancellation in the language.

So Promise.all does the only thing it can: it stops listening. It rejects its own promise with the first reason it sees and leaves the rest to finish into the void.

That has a second consequence, and this one is worse because it is invisible:

process.on('unhandledRejection', (e) => console.log('!! unhandledRejection:', e.message));

const slowFail = new Promise((_, rej) =>
  setTimeout(() => rej(new Error('second failure')), 50),
);

try {
  await Promise.all([Promise.reject(new Error('first failure')), slowFail]);
} catch (err) {
  console.log('caught:', err.message);
}

Output:

caught: first failure

second failure never appears — not as a caught error, not as an unhandledRejection. Promise.all attached its own handler to every input, so the runtime considers that rejection handled; but its result promise was already settled, so the second reason is dropped on the floor. You lose the error entirely. If B and C both failed for the same underlying reason, you will debug B and never learn C existed.

Here is how the four combinators actually differ:

CombinatorSettles whenOn rejectionThe other promises
Promise.allall fulfil, or first rejectsrejects with the first reasonkeep running; later rejections discarded
Promise.allSettledevery input settlesnever rejectsall run to completion, every outcome reported
Promise.anyfirst fulfils, or all rejectAggregateError after all rejectkeep running
Promise.racefirst input settlesrejects if that first one rejectedkeep running

Note that none of the four cancels anything. The column is the same for all of them.

What to do

If you need to know every outcome, use allSettled. It never rejects, so you inspect statuses instead of catching:

const results = await Promise.allSettled(tasks);
const failed = results.filter((r) => r.status === 'rejected');
if (failed.length) {
  console.error(`${failed.length} of ${results.length} failed`);
  for (const f of failed) console.error(f.reason);
}

This is the fix for the swallowed-second-error problem. You get all the reasons, not just the fastest one.

If you actually want to stop the work, pass a signal and abort it. Write the tasks as functions that take a signal, so nothing starts until you call them:

async function allOrAbort(taskFns) {
  const controller = new AbortController();
  try {
    return await Promise.all(taskFns.map((fn) => fn(controller.signal)));
  } catch (err) {
    controller.abort();
    throw err;
  }
}

// each task decides what aborting means for it
const rows = await allOrAbort([
  (signal) => fetch('https://example.com/a', { signal }),
  (signal) => fetch('https://example.com/b', { signal }),
]);

fetch honours signal and rejects with an AbortError. Anything you write yourself has to check signal.aborted at its own checkpoints, or listen for the signal’s abort event — aborting does not interrupt a running function.

If the operations have side effects, make the failure recoverable rather than preventable. You cannot un-send a webhook. Either make each write idempotent and retry, or sequence them so a failure stops the next one from starting:

for (const fn of taskFns) {
  await fn(); // a throw here means later ones never begin
}

That trades all the concurrency away, which is the actual cost of ordering guarantees.

Caveats

References