JavaScript's Promise.all Fail-Fast Trap: The Sibling Requests That Keep Charging After You've Given Up

2026-09-07

This function processes an order in parallel: charge the card, validate the shipping address, and reserve inventory. If any step fails, the order is cancelled and inventory released. It has been in production for months, works perfectly in tests, and quietly charges roughly one in every two hundred rejected orders.

async function processOrder(order) {
  try {
    await Promise.all([
      chargeCard(order.card, order.total),
      validateAddress(order.address),
      reserveInventory(order.items),
    ]);
    return { status: 'confirmed', orderId: order.id };
  } catch (err) {
    // Something failed — release the hold and tell the customer.
    await releaseInventory(order.items);
    return { status: 'failed', reason: err.message };
  }
}

// Called from the HTTP handler:
const result = await processOrder(order);
res.json(result);

The Bug

Promise.all is fail-fast on the returned promise, not on the inputs. The moment any input promise rejects, the aggregate promise rejects — but the sibling promises keep running to completion. They have already been invoked; there is no cancellation mechanism, and JavaScript has no concept of "un-firing" a network request.

Picture the race. validateAddress is a synchronous-ish call to a local ZIP database and rejects in 5 ms on an invalid postal code. chargeCard is a 400 ms round trip to Stripe. The sequence:

Worse, the eventual resolution of chargeCard is now an orphan. If it rejects, it becomes an unhandled promise rejection (Node may crash the process depending on config). If it resolves, the charge silently succeeds and there is no code path that will ever refund it.

Tests miss it because mocked promises resolve synchronously in the same microtask tick, so chargeCard "finishes" before validateAddress rejects. Production hits it because real network calls are asynchronous and unpredictable.

The Fix

Use Promise.allSettled so every promise runs to completion before you decide what to compensate. Then inspect each result and roll back any side effect that actually happened:

async function processOrder(order) {
  const [chargeResult, addressResult, inventoryResult] =
    await Promise.allSettled([
      chargeCard(order.card, order.total),
      validateAddress(order.address),
      reserveInventory(order.items),
    ]);

  const failed = [chargeResult, addressResult, inventoryResult]
    .filter(r => r.status === 'rejected');

  if (failed.length > 0) {
    // Compensate only for the operations that actually succeeded.
    if (chargeResult.status === 'fulfilled') {
      await refundCharge(chargeResult.value);
    }
    if (inventoryResult.status === 'fulfilled') {
      await releaseInventory(order.items);
    }
    return { status: 'failed', reason: failed[0].reason.message };
  }

  return { status: 'confirmed', orderId: order.id };
}

The deeper lesson: Promise.all is safe for pure reads, where a fast rejection just wastes the other queries. It is never safe for operations with irreversible side effects unless you also plan for what to do with the siblings' eventual results. The AbortController pattern helps only if every one of those APIs actually respects the signal — and card processors, by design, do not.

Key Takeaway: Promise.all rejects fast but its siblings run to completion — never parallelize side-effecting operations without allSettled and explicit compensation for whichever ones actually succeeded.

All newsletters