DEV Community

Cover image for Race Conditions and Fallacies of `Promise.all`
Domi
Domi

Posted on • Edited on

Race Conditions and Fallacies of `Promise.all`

What fallacies does Promise.all have?

Promise.all is a convinient synchronization mechanism. However, unbeknownst to many, when facing rejections, Promise.all can cause two big headaches:

  1. Upon the first rejection, the promise created with Promise.all will settle with that first rejection. All its other unsettled promises are now "dangling". This means that code chained/nested in those promises is now running concurrently with all code that is chained after Promise.all. This can lead to ugly race conditions if you are not aware of and explicitely consider that possibility.
  2. If you have more than one rejection, any rejection that is not the first, not only is "dangling", but is explicitly muted. The JS engine will not report any of its unhandled rejections, that is not the first.

What about Promise.allSettled?

Promise.allSettled is not quite as user-friendly and even foregoes promise error/rejection handlers. You have to provide a fulfillment handler and manually loop over the results array to decipher whether you have any errors at all (i.e. Promise.allSettled(...).then(results => ...)). It's OK, if you make sure that you diligently handle all the information, but it makes things quite a bit more convoluted.

Solution

I present an alternative to Promise.all which uses Promise.allSettled and aggregates all errors.

NOTE: Just like, Promise.all, it ignores fulfilled values, in case of any rejection.

Pros:

  • Easy to use alternative to Promise.all that does not allow dangling, thereby preventing race conditions.
  • Reports all errors, not only the first

Cons:

  • Error aggregation mangles error objects into one big string. That can be further improved.
  • Promise chain not moving forward on first rejection can slow things down considerably in case the first error happens fast, but the critical path is slow.

The following code is also available in this gist:

/**
 * Fixes the "dangling problem" of Promise.all.
 *
 * {@link betterPromiseAll}
 * @see https://dev.to/domiii/a-solution-to-the-deep-flaws-of-promiseall-4aon-temp-slug-8454028
 */
async function promiseAll(promises) {
  const results = await Promise.allSettled(promises);
  const values = [];
  const errors = [];

  for (const result of results) {
    if (result.status === 'rejected') {
      errors.push(result.reason);
    }
    else {
      values.push(result.value);
    }
  }

  if (errors.length) {
    // NOTE: `AggregateError` seems not too mature YET. It's internal `errors` property is (currently, as of 2/2022) NOT rendered when reported, so we do some manual aggregation for now.
    // throw new AggregateError(errors, 'Promise.allSettled rejections');
    throw new Error(`${errors.length} promise rejections: ${errors.map((err, i) => `\n  [${i + 1}] ${err.stack || err}`).join('')}\n------`);
  }
  return values;
}

/** ###########################################################################
 * some samples
 * ##########################################################################*/
async function runSample(cb) {
  try {
    const result = await cb();
    console.log('########\nFULFILL:\n', result);
  }
  catch (err) {
    console.error('########\nREJECT:\n', err);
  }
}

// reject
runSample(() => {
  return betterPromiseAll([
    Promise.reject(1), 
    Promise.reject(new Error(2)), 
    Promise.resolve().then(() => { throw new Error(3); })
  ]);
});

// reject
runSample(() => {
  return betterPromiseAll([
    Promise.resolve(1),
    Promise.reject(new Error(2)), 
    Promise.resolve().then(() => { throw new Error(3); })
  ]);
});

// fulfill
runSample(() => {
  return betterPromiseAll([
    Promise.resolve(1),
    Promise.resolve(2),
    Promise.resolve(3)
  ]);
});
Enter fullscreen mode Exit fullscreen mode

Top comments (0)