JavaScript Promise combinators: .all(), .race(), .allSettled()

[2019-08-07] dev, javascript, es feature, es2020, promises
(Ad, please don’t block)
  • Update 2019-08-07: Complete rewrite of section “Overview”.

In this blog post, we take a look at three static methods of Promise:

  • Promise.all() and Promise.race() which JavaScript has had since ECMAScript 6 when Promises were added to the language.
  • Promise.allSettled() which recently advanced to stage 4 and will therefore be part of ECMAScript 2020.

Overview  

Each of the following methods receives an iterable over input Promises and returns a single output Promise P.

Promise.all<T>(promises: Iterable<Promise<T>>)
  : Promise<Array<T>>
  • Fulfillment of P: if all input Promises are fulfilled.
    • Value: Array with the fulfillment values of the input Promises
  • Rejection of P [SC]: if one input Promise is rejected.
    • Value: rejection value of the input Promise
  • Use case: processing Arrays with Promises (rejections terminate processing)
  • ECMAScript 6
Promise.race<T>(promises: Iterable<Promise<T>>)
  : Promise<T>
  • Settlement of P [SC]: if the first input Promise is settled.
    • Value: settlement value of the input Promise
  • Use case: reacting to the first settlement among multiple Promises
  • ECMAScript 6
Promise.allSettled<T>(promises: Iterable<Promise<T>>)
  : Promise<Array<SettlementObject<T>>>
  • Fulfillment of P: if all input Promise are settled.
    • Value: Array with one settlement object for each input Promise. A settlement object contains the kind of settlement and the settlement value
  • Rejection of P: never (*)
  • Use case: processing Arrays with Promises (rejections don’t terminate processing)
  • ECMAScript 2020

Legend:

  • [SC] means that short-circuiting happens: the output Promise is settled before every input Promise is settled.
  • (*) If there are errors when iterating over the input iterables, then those produce rejections.

Recap: Promise states  

Given an asynchronous operation that returns a Promise. These are possible states of the Promise:

  • Pending: the initial state of a Promise. The operation isn’t finished, yet.
  • Fulfilled: The operation succeeded and provided a fulfillment value for the Promise.
  • Rejected: The operation failed and provided a rejection value for the Promise.
  • Settled: The Promise is either fulfilled or rejected. Once a Promise is settled, its state doesn’t change anymore.

What is a combinator?  

The combinator pattern is a pattern in functional programming for building structures. It is based on two kinds of functions:

  • Primitive functions (short: primitives) create atomic pieces.
  • Combinator functions (short: combinators) combine atomic and/or compound pieces to create compound pieces.

When it comes to JavaScript Promises:

  • Primitive functions include: Promise.resolve(), Promise.reject()
  • Combinators include: Promise.all(), Promise.race(), Promise.allSettled()

Next, we’ll take a closer look at Promise.all(), Promise.race(), and Promise.allSettled().

Promise.all()  

This is the type signature of Promise.all():

Promise.all<T>(promises: Iterable<Promise<T>>): Promise<Array<T>>

Promise.all() returns a Promise which is:

  • Fulfilled if all promises are fulfilled.
    • Then its fulfillment value is an Array with the fulfillment values of promises.
  • Rejected if at least one Promise is rejected.
    • Then its rejection value is the rejection value of that Promise.

This is a quick demo of the output Promise being fulfilled:

const promises = [
  Promise.resolve('result a'),
  Promise.resolve('result b'),
  Promise.resolve('result c'),
];
Promise.all(promises)
  .then((arr) => assert.deepEqual(
    arr, ['result a', 'result b', 'result c']
  ));

The following example demonstrates what happens if at least one of the input Promises is rejected:

const promises = [
  Promise.resolve('result a'),
  Promise.resolve('result b'),
  Promise.reject('ERROR'),
];
Promise.all(promises)
  .catch((err) => assert.equal(
    err, 'ERROR'
  ));

The following diagram illustrates how Promise.all() works:

Asynchronous .map() via Promise.all()  

Array transformation methods such as .map(), .filter(), etc., are made for synchronous computations. For example:

function timesTwoSync(x) {
  return 2 * x;
}
const arr = [1, 2, 3];
const result = arr.map(timesTwoSync);
assert.deepEqual(result, [2, 4, 6]);

What happens if the callback of .map() is a Promise-based function (a function that maps normal values to Promises)? Then the result of .map() is an Array of Promises. Alas, that is not data that normal code can work with. Thankfully, we can fix that via Promise.all(): It converts an Array of Promises into a Promise that is fulfilled with an Array of normal values.

function timesTwoAsync(x) {
  return new Promise(resolve => resolve(x * 2));
}
const arr = [1, 2, 3];
const promiseArr = arr.map(timesTwoAsync);
Promise.all(promiseArr)
  .then(result => {
    assert.deepEqual(result, [2, 4, 6]);
  });

A more realistic .map() example  

Next, we’ll use .map() and Promise.all() to downlooad text files from the web. For that, we need the following tool function:

function downloadText(url) {
  return fetch(url)
    .then((response) => { // (A)
      if (!response.ok) { // (B)
        throw new Error(response.statusText);
      }
      return response.text(); // (C)
    });
}

downloadText() uses the Promise-based fetch API to download a text file as a string:

  • First, it asynchronously retrieves a response (line A).
  • response.ok (line B) checks if there were errors such as “file not found”.
  • If there weren’t any, we use .text() (line C) to retrieve the content of the file as a string.

In the following example, we download two text files:

const urls = [
  'http://example.com/first.txt',
  'http://example.com/second.txt',
];

const promises = urls.map(
  url => downloadText(url));

Promise.all(promises)
  .then(
    (arr) => assert.deepEqual(
      arr, ['First!', 'Second!']
    ));

A simple implementation of Promise.all()  

This is a simplified implementation of Promise.all() (e.g., it performs no safety checks):

function all(iterable) {
  return new Promise((resolve, reject) => {
    let index = 0;
    for (const promise of iterable) {
      // Capture the current value of `index`
      const currentIndex = index;
      promise.then(
        (value) => {
          if (anErrorOccurred) return;
          result[currentIndex] = value;
          elementCount++;
          if (elementCount === result.length) {
            resolve(result);
          }
        },
        (err) => {
          if (anErrorOccurred) return;
          anErrorOccurred = true;
          reject(err);
        });
      index++;
    }
    if (index === 0) {
      resolve([]);
      return;
    }
    let elementCount = 0;
    let anErrorOccurred = false;
    const result = new Array(index);
  });
}

Promise.race()  

This is the type signature of Promise.race():

Promise.race<T>(promises: Iterable<Promise<T>>): Promise<T>

Promise.race() returns a Promise q which is settled as soon as the first Promise p among promises is settled. q has the same settlement value as p.

In the following demo, the settlement of the fulfilled Promise (line A) happens before the settlement of the rejected Promise (line B). Therefore, the result is also fulfilled (line C).

const promises = [
  new Promise((resolve, reject) =>
    setTimeout(() => resolve('result'), 100)), // (A)
  new Promise((resolve, reject) =>
    setTimeout(() => reject('ERROR'), 200)), // (B)
];
Promise.race(promises)
  .then((result) => assert.equal( // (C)
    result, 'result'));

In the next demo, the rejection happens first:

const promises = [
  new Promise((resolve, reject) =>
    setTimeout(() => resolve('result'), 200)),
  new Promise((resolve, reject) =>
    setTimeout(() => reject('ERROR'), 100)),
];
Promise.race(promises)
  .then(
    (result) => assert.fail(),
    (err) => assert.equal(
      err, 'ERROR'));

Note that the Promise returned by Promise.race() is settled as soon as the first among its input Promises is settled. That means that the result of Promise.race([]) is never settled.

The following diagram illustrates how Promise.race() works:

Using Promise.race() to time out a Promise  

In this section, we are going to use Promise.race() to time out Promises. The following helper function will be useful several times:

function resolveAfter(ms, value=undefined) {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(value), ms);
  });
}

resolveAfter() returns a Promise that is resolved with value after ms milliseconds.

This function times out a Promise:

function timeout(timeoutInMs, promise) {
  return Promise.race([
    promise,
    resolveAfter(timeoutInMs,
      Promise.reject(new Error('Operation timed out'))),
  ]);
}

timeout() returns a Promise whose settlement is the same as the one of whichever Promise settles first among the following two:

  1. The parameter promise
  2. A Promise that is rejected after timeoutInMs milliseconds

To produce the second Promise, timeout() uses the fact that resolving a pending Promise with a rejected Promise leads to the former being rejected.

Let’s see timeout() in action. Here, the input Promise is fulfilled before the timeout. Therefore, the output Promise is fulfilled.

timeout(200, resolveAfter(100, 'Result!'))
  .then(result => assert.equal(result, 'Result!'));

Here, the timeout happens before the input Promise is fulfilled. Therefore, the output Promise is rejected.

timeout(100, resolveAfter(2000, 'Result!'))
  .catch(err => assert.deepEqual(err, new Error('Operation timed out')));

It is important to understand what “timing out a Promise” really means:

  • If the input Promise is settled quickly enough, its settlement is passed on to the output Promise.
  • If it isn’t settled quickly enough, the output Promise is rejected.

That is, timing out only prevents the input Promise from affecting the output (since a Promise can only be settled once). But it does not stop the asynchronous operation that produced the input Promise. That is a different subject matter.

A simple implementation of Promise.race()  

This is a simplified implementation of Promise.race() (e.g., it performs no safety checks):

function race(iterable) {
  return new Promise((resolve, reject) => {
    for (const promise of iterable) {
      promise.then(
        (value) => {
          if (settlementOccurred) return;
          settlementOccurred = true;
          resolve(value);
        },
        (err) => {
          if (settlementOccurred) return;
          settlementOccurred = true;
          reject(err);
        });
    }
    let settlementOccurred = false;
  });
}

Promise.allSettled()  

The feature Promise.allSettled was proposed by Jason Williams, Robert Pamely, and Mathias Bynens.

This time, the type signatures are a little more complicated. Feel free to skip ahead to the first demo which should be easier to understand.

This is the type signature of Promise.allSettled():

Promise.allSettled<T>(promises: Iterable<Promise<T>>)
  : Promise<Array<SettlementObject<T>>>

It returns a Promise for an Array whose elements have the following type signature:

type SettlementObject<T> = FulfillmentObject<T> | RejectionObject;

interface FulfillmentObject<T> {
  status: 'fulfilled';
  value: T;
}

interface RejectionObject {
  status: 'rejected';
  reason: unknown;
}

Promise.allSettled() returns a Promise out. Once all promises are settled, out is fulfilled with an Array. Each element e of that Array corresponds to one Promise p of promises:

  • If p is fulfilled with the fulfillment value v, then e is
    { status: 'fulfilled', value:  v }
    
  • If p is rejected with the rejection value r, then e is
    { status: 'rejected',  reason: r }
    

Unless there is an error when iterating over promises, the output Promise out is never rejected.

The following diagram illustrates how Promise.allSettled() works:

A first demo of Promise.allSettled()  

This is a quick first demo of how Promise.allSettled() works:

Promise.allSettled([
  Promise.resolve('a'),
  Promise.reject('b'),
])
.then(arr => assert.deepEqual(arr, [
  { status: 'fulfilled', value:  'a' },
  { status: 'rejected',  reason: 'b' },
]));

A longer example for Promise.allSettled()  

The next example is similar to the .map() plus Promise.all() example (from which we are borrowing the function downloadText()): We are downloading multiple text files whose URLs are stored in an Array. However, this time, we don’t want to stop when there is an error, we want to keep going. Promise.allSettled() allows us to do that:

const urls = [
  'http://example.com/exists.txt',
  'http://example.com/missing.txt',
];

const result = Promise.allSettled(
  urls.map(u => downloadText(u)));
result.then(
  arr => assert.deepEqual(
    arr,
    [
      {
        status: 'fulfilled',
        value: 'Hello!',
      },
      {
        status: 'rejected',
        reason: new Error('Not Found'),
      },
    ]
));

A simple implementation of Promise.allSettled()  

This is a simplified implementation of Promise.allSettled() (e.g., it performs no safety checks):

function allSettled(iterable) {
  return new Promise((resolve, reject) => {
    function addElementToResult(i, elem) {
      result[i] = elem;
      elementCount++;
      if (elementCount === result.length) {
        resolve(result);
      }
    }

    let index = 0;
    for (const promise of iterable) {
      // Capture the current value of `index`
      const currentIndex = index;
      promise.then(
        (value) => addElementToResult(
          currentIndex, {
            status: 'fulfilled',
            value
          }),
        (reason) => addElementToResult(
          currentIndex, {
            status: 'rejected',
            reason
          }));
      index++;
    }
    if (index === 0) {
      resolve([]);
      return;
    }
    let elementCount = 0;
    const result = new Array(index);
  });
}

Availability  

The npm package Promise.allSettled contains the official polyfill.

(Advanced)  

All remaining sections are advanced.

Short-circuiting  

For a Promise combinator, short-circuiting means that the output Promise is settled early – before all input Promises are settled. Two combinators short-circuit:

  • Promise.all(): The output Promise is rejected as soon as one input Promise is rejected.
  • Promise.race(): The output Promise is settled as soon as one input Promise is settled.

Once again, settling early does not mean that the operations behind the ignored Promises are stopped. It just means that their settlements are ignored.

Concurrency and Promise.all()  

Sequential execution vs. concurrent execution  

Consider the following code:

const asyncFunc1 = () => Promise.resolve('one');
const asyncFunc2 = () => Promise.resolve('two');

asyncFunc1()
  .then(result1 => {
    assert.equal(result1, 'one');
    return asyncFunc2();
  })
  .then(result2 => {
    assert.equal(result2, 'two');
  });

Using .then() in this manner executes Promise-based functions sequentially: only after the result of asyncFunc1() is settled will asyncFunc2() be executed.

Promise.all() helps execute Promise-based functions more concurrently:

Promise.all([asyncFunc1(), asyncFunc2()])
  .then(arr => {
    assert.deepEqual(arr, ['one', 'two']);
  });

Concurrency tip: focus on when operations start  

Tip for determining how “concurrent” asynchronous code is: Focus on when asynchronous operations start, not on how their Promises are handled.

For example, each of the following functions executes asyncFunc1() and asyncFunc2() concurrently because they are started at nearly the same time.

function concurrentAll() {
  return Promise.all([asyncFunc1(), asyncFunc2()]);
}

function concurrentThen() {
  const p1 = asyncFunc1();
  const p2 = asyncFunc2();
  return p1.then(r1 => p2.then(r2 => [r1, r2]));
}

On the other hand, both of the following functions execute asyncFunc1() and asyncFunc2() sequentially: asyncFunc2() is only invoked after the Promise of asyncFunc1() is fulfilled.

function sequentialThen() {
  return asyncFunc1()
    .then(r1 => asyncFunc2()
      .then(r2 => [r1, r2]));
}

function sequentialAll() {
  const p1 = asyncFunc1();
  const p2 = p1.then(() => asyncFunc2());
  return Promise.all([p1, p2]);
}

Promise.all() is fork-join  

Promise.all() is loosely related to the concurrency pattern “fork join”. Let’s revisit an example that we have encountered previously:

Promise.all([
    // (A) fork
    downloadText('http://example.com/first.txt'),
    downloadText('http://example.com/second.txt'),
  ])
  // (B) join
  .then(
    (arr) => assert.deepEqual(
      arr, ['First!', 'Second!']
    ));
  • Fork: In line A, we are forking two asynchronous computations and executing them concurrently.
  • Join: In line B, we are joining these computations into a single “thread” which is started once all of them are done.

Further reading