Sam Jarman

View Original

You're Probably Not Using Promise.All Enough

Before I begin: This is not a full tutorial on the keywords mentioned here. This is an essay on using Promise.all more effectively. Google is your friend.

Promises before - "callback hell"

Ever since ES8, JavaScript developers have probably been enjoying the new keywords async and await. Often called 'async/await', this set of keywords solves a problem in JavaScript up until this point: "Callback hell".

Prior to ES8, functions that returned asynchronously had to accept callbacks. This meant the code got messy when you had to do multiple asynchronous steps.

Here's an example

function main() {
  return doSomethingAsync('Foo').then(result1 => {
    return doSomethingElseAsync('Foo').then(result2 => {
      // Now that I have my items, call the final step
      return finallySomethingAsync(result1, result2);
    });
  });
}

See how the code drifts off to the right? It's not ideal. This has two steps, but you can imagine the nesting with three, five or ten steps. Gross.

Promises now - just lovely

As Async/Await came along, the same code could be expressed much more nicely.

async function main() {
  const result1 = await doSomethingAsync('Foo');
  const result2 = await doSomethingElseAsync('Foo');

  // Now that I have my items, call the final step
  return await finallySomethingAsync(result1, result2);
}

See how that looks more like synchronous code? Nice steps laid out that are easy to follow.

And that's usually where the tutorials end for this topic. However I'd like to go into why you might want to go further when converting this code.

Similar to the first snippet, the code waits twice. Once to get result1 and again to get result2. These are then used together to do the final step.

Where you start to have issues is when you realise you don't actually need to wait for these things in sequence. They can happen in parallel.

Promise.all

So, we introduce Promise.all. Promise.all waits for an array of promises to resolve before continuing. So, if we change our code to use Promise.all instead, it'd look like this:

async function main() {
  console.log('This is my code');
  const [result1, result2] = await Promise.all([
    doSomethingAsync('Foo'),
    doSomethingElseAsync('Foo'),
  ]);

  // Now that I have my items, call the final step
  return await finallySomethingAsync(result1, result2);
}

Walking through, we declare the result variables using destructuring assignment, and then await the call to Promise.all.

From there we can then use the two variables in the final call.

What we've done essentially is cut our wait time in half. Instead of waiting for a 2 x methods that take a second each, resulting in two second series step. We've done in them parallel and now they take about close to one second. That's a great time saving for you and your user.

Now, a subtlety here: really, the definition of Promise.all is not executing in parallel. It's awaiting a list to finish. The difference is the call to doSomethingAsync has probably started a few clock cycles sooner than doSomethingElseAsync. Usually this difference doesn't matter, but expect to see the operations of equal duration length finish in an indeterministic order.

So: if you have code that needs to make a series of async calls - think to yourself - can any of these done in parallel? In the example above we did two of the three in parallel because the third one needed the results of the first two. However, the second didn't need the result of the first, so could be done at the same time.

Awaiting dynamic arrays of promises

Where this comes in really handy is when you're mapping over a list of say, users, and updating a record of them.

Often, inexperienced programmers will avoid map and opt for a for...of pattern instead. Perhaps the loop used to be synchronous and now it has some async code in there. Either way, it happens. However, when loops are combined with async await, it can cause some very slow code.

async function main2() {
  const users = ['Sam', 'Hannah', 'Craig', 'Morgan'];

  let results = [];

  for await (const user of users) {
    const result = await doSomethingAsync(user);

    results.push('Hello, ' + result);
  }

  return results;
}

Here, we are actually waiting for the previous loop of the for..of loop to finish before we start the next one. However, we absolutely shouldn't be doing that, as the requests don't rely on each other, and can be kicked off together and await'd in parallel

const users = ['Sam', 'Hannah', 'Craig', 'Morgan'];
  const results = await Promise.all(users.map(async (user) => {
    const result = await doSomethingAsync(user);
    return 'Hello, ' + result;
  }));

  return results;
}

Here, we use Array.map to create a array of promises, and then we await that array of promises with Promise.all again.

Once again, if doSomethingAsync takes one second, then the sequential time is four seconds for our four users, but in parallel it'll likely be closer to one second. A huge improvement!

Final thoughts

Writing code like this does make it less easy to follow - its definitely less sequential, but with time it gets easier to read and write. A good handle on .map and Promises will serve you well with your JavaScript development. All of the above applies to TypeScript, flow and is the same no matter if you're in Node or the web, using react, vue or whatever. This is a vanilla JavaScript problem with a vanilla JavaScript solution.

Final flex: I rewrote a node job recently, and using Promise.all it went from about 6 seconds to about 2. It's worth doing.