The Power and Limitations of JavaScript Promise.all

The power of parallel when writing asynchronous JavaScript — and the traps to look out for!

Published on
Jun 3, 2021

Read time
4 min read

Introduction

In many web development projects, important functionality depends on asynchronous code — whether that’s making HTTPS requests to an API, querying and updating a database, or managing time-sensitive tasks like reading large files.

But are you making sure that your asynchronous code is optimised to run as efficiently as possible?

When I was newer to asynchronous JavaScript, I would often await every promise without realising that I was creating unnecessary bottlenecks in my code. Once I understood some of the benefits of Promise.all, I went through a stage of using it too much. In specific scenarios, my code became inconsistent because I was trying to do things in parallel that affected one another, and the task that happened first was whichever one was fastest!

In this article, we’ll look into the power of parallel while also being mindful of the dangers of using Promise.all too liberally.

How Promise.all Works

We’ll start with a basic example. First, we’ll create an array of numbers from 1 to 100:

const nums = new Array(100).fill(1).map((num, i) => i + 1);

Next, we’ll create an asynchronous function that takes 50 milliseconds to complete. To help keep the example simple, we’ll rely on useTimeout to simulate an API call rather than actually making an HTTP request:

const asyncDouble = (num) => {
  return new Promise((resolve, reject) =>
    setTimeout(() => {
      return resolve(num * 2);
    }, 50)
  );
};

Our asynchronous function takes a number and doubles it. Now let’s imagine we want to call this function for every item in our array.

If this was synchronous code, we might opt for map:

const doubledNums = nums.map((num) => num * 2);

But what would happen if we tried this using our asyncDouble function?

const doubledNums = nums.map(async (num) => await asyncDouble(num));

Although we’ve added the async and await keywords, there’s a problem. The doubledNums that we’ve logged to the console is full of unresolved promises! That’s because map isn’t built to handle promises.

Another option is to use a for loop, like so:

const doubledNums = [];

for (const num of nums) {
  doubledNums.push(await asyncDouble(num));
}

Now we’re getting the result we want, but each call to promiseFunc only takes place after the previous call has resolved. It takes five seconds to get the end result. If we needed to query a lot of values, this could take a long time!

This is where Promise.all comes in. It triggers every promise in parallel and resolves only when every other promise has resolved:

const doubledNums = await Promise.all(nums.map((num) => asyncDouble(num)));

Now, running in parallel, this takes approximately 50 milliseconds — the same length of time as a single instance of promiseFunc. You are saving a lot of time in comparison to running each promise in turn.

How Many Promises Can We Resolve at Once?

How far can we push Promise.all and how many promises can we trigger at once? It depends, of course, on a range of factors like processing power and the complexity of the promises involved.

Assuming we have the processing power and that our promises can run in parallel, there is a hard limit of just over 2 million promises.

If we dig into the code of V8, the JavaScript engine behind Chrome and Node.js, we can see that a TooManyElementsInPromiseAll error is triggered for arrays of length 2**21 or longer. So a length of 2,097,150 is acceptable, while 2,097,151 throws an error.

// Original source: v8/test/mjsunit/es6/promise-all-overflow-1.js
// https://github.com/v8/v8/blob/4b9b23521e6fd42373ebbcb20ebe03bf445494f9/test/mjsunit/es6/promise-all-overflow-1.js#L9-L12

// Make sure we properly throw a RangeError when overflowing the maximum
// number of elements for Promise.all, which is capped at 2^21 bits right
// now, since we store the indices as identity hash on the resolve element
// closures.

const a = new Array(2 ** 21 - 1);
const p = Promise.resolve(1);
for (let i = 0; i < a.length; ++i) a[i] = p;
testAsync((assert) => {
  assert.plan(1);
  Promise.all(a).then(assert.unreachable, (reason) => {
    assert.equals(true, reason instanceof RangeError);
  });
});

Few of us will be working anywhere near that limit. However, that doesn’t mean we can always run promises in parallel. For example, if you’re working with a database, you might be limited by the number of connections you can make at once. Or if you’re working with a third-party API, you might be limited by the number of requests you can make in a given time frame.

Another common issue is trying to run codependent promises in parallel.

What Are the Limitations of Promise.all?

Finally, a word of warning. Even if one function doesn’t rely directly on the result of another function, they may still influence the same fields in ways where execution order is important.

Let’s imagine a simple MongoDB database consisting of pizza types, their prices (in cents), and their order on the menu:

[
  {
    type: "Quattro formaggi",
    price: 1200,
    order: 0,
  },
  {
    type: "Hawaiian",
    price: 1000,
    order: 1,
  },
  {
    type: "Margherita",
    price: 800,
    order: 2,
  },
];

We want to update our pricing using three asynchronous functions:

  • One that applies a 50% price increase across the board.
  • Another that applies a $5 discount to the Hawaiian pizza.
  • A third that updates the menu order from the cheapest pizza to the most expensive.

What happens if we try to run them asynchronously?

await Promise.all([inflatePrices(), discountHawaiian(), sortByPrice()]);

We can’t guarantee that one of these functions will always be the fastest and another will always be the slowest, which means the promises could execute in any order. Because these functions are dependent on one another, this introduces consistency into our final result.

If inflatePrices executes before discountHawaiian executes, our Hawaiian pizza will cost $10.00. But if the functions execute in the reverse order, it will cost $7.50. Depending on sortByPrice, this pizza may or may not be ordered correctly, given its final price.

In this situation, it’s far better to stomach a negligible reduction in performance for code that has a consistent, repeatable outcome — like below:

await inflatePrices();
await discountHawaiian();
await sortByPrice();

It’s not always this easy to spot functions that depend on one another. For that reason, it’s important to stay vigilant about over-optimising with Promise.all — especially if you don’t fully understand everything that your asynchronous functions might be doing.

But when you know you can safely resolve promises in parallel, Promise.all is an extremely useful tool to help speed up your code!

© 2024 Bret Cameron