You Should Use Parallel Promises

You Should Use Parallel Promises

handling promises in parallel | performance gains | the Promise.all() method.

·

6 min read

Asynchronous JavaScript was introduced to help implement non-blocking code execution. Async-await took it up a notch by making it easier to work with promises and avoid the infamous callback hell.

// log every second -> callback hell version

setTimeout(() => {
  console.log('1 second');
  setTimeout(() => {
    console.log('2 seconds');
    setTimeout(() => {
      console.log('3 seconds');
      setTimeout(() => {
        console.log('4 seconds');
        setTimeout(() => {
          console.log('5 seconds');
        }, 1000);
      }, 1000);
    }, 1000);
  }, 1000);
}, 1000);

/* 
OUTPUT:
1 second
2 seconds
3 seconds
4 seconds
5 seconds
*/
// log every second -> async-await version

const wait = function(time) {
  return new Promise(resolve => setTimeout(resolve, time * 1000))
}

async function clock() {
  try {
    await wait(1);
    console.log('1 second');

    await wait(1);
    console.log('2 seconds');

    await wait(1);
    console.log('3 seconds');

    await wait(1);
    console.log('4 seconds');

    await wait(1);
    console.log('5 seconds');
  } catch (err) {
    console.log(err);
  }
}
clock();

/* 
OUTPUT:
1 second
2 seconds
3 seconds
4 seconds
5 seconds
*/

However, one has to be a bit cautious while using await because it blocks code i.e when await is used, code execution stops until the promise that it is waiting for is settled (either resolved or rejected). This is different from the .then and .catch methods that were used before it, where code execution would not pause. Regular code execution would continue and .then or .catch would be called only when the promise had settled.

When you have multiple independent await statements in your code block, all those statements become blocking statements, potentially breaking the async nature and the core advantage of using async-await in the first place.

Real-world example

I'm loading three images from a remote server using the fetch method. Since fetch is async and I like modern JavaScript, I take the async-await approach for all asynchronous operations. I then load these images onto the DOM. I also implement a fairly basic timer that works using Date to get a rough estimate of how much it takes to finish the entire operation.

Here's the code:

const imgContainer = document.querySelector('.img-container');

// simple timer code
let time = '';
function resetTime() {
  time = new Date();
}
function elapsedTime(type) {
  const now = new Date();
  console.log(type, (now - time) / 1000);
}

// fetch photo from server
async function fetchImage(w = 200, h = 300) {
  try {
    const data = await fetch(`https://picsum.photos/${w}/${h}/`);
    return data.url;
  } catch (err) {
    return new Error(err);
  }
}

// load photos to DOM
function loadImages(...sources) {
  sources.forEach((source) => {
    const img = document.createElement('img');
    img.src = source;
    imgContainer.append(img);
  });
}


(async function () {
  resetTime();
  try {
    const photo1 = await fetchImage(700, 800);
    const photo2 = await fetchImage(50, 50);
    const photo3 = await fetchImage(100, 200);
    loadImages(photo1, photo2, photo3);
  } catch (err) {
    console.log(err);
  }
  elapsedTime('one-by-one');
})();

I've embedded a sandbox below that runs the code snippet above. Make note of the time it takes to finish the operation. It took between 2 and 3 seconds for me.

So what's the problem here?

The three await statements block code execution at each step, making the images get fetched only one after the other. However, the three fetchImage calls can actually be made independently of one another since fetch was designed to be asynchronous. The only requirement for us is that all three promises should settle before loadImages is called so that valid values can be passed in as the arguments.

So, by using multiple await statements one after the other, what was supposed to be an async load operation has now become a synchronous, time-consuming one.

This can also be verified by inspecting the Network tab in the Dev Tools when running the code locally. Notice how the activities only happen sequentially.

If the loadImage call is temporarily commented out and the await keyword is removed, you can observe the three fetch statements happening asynchronously.

(async function () {
  resetTime();
  try {
    const photo1 = fetchImage(700, 800);
    const photo2 = fetchImage(50, 50);
    const photo3 = fetchImage(100, 200);
    // loadImages(photo1, photo2, photo3);
  } catch (err) {
    console.log(err);
  }
  elapsedTime('one-by-one');
})();

Enter Promise.all()

By now, I hope it is established that having multiple independent await statements is not a good idea for performance. The solution to this problem is to use the Promise.all() method

It takes an iterable of promises as input and returns a single promise. An array of promises is usually passed in as the iterable. The returned promise resolves when all the input promises get fulfilled. It rejects when any one of the input promises reject.

Here's how we modify our existing code

  • Omit the await keyword from the three fetchImage() calls so that photo1, photo2, and photo3 become simple promises that will get a resolved value at a future time.

  • Call the Promise.all() method and pass in the three promises as an array

  • Handle the settled value of this promise.


    const photo1 = fetchImage(700, 800);
    const photo2 = fetchImage(50, 50);
    const photo3 = fetchImage(100, 200);

    const allPromises = await Promise.all([photo1, photo2, photo3]);

If allPromises gets fulfilled, the fulfilled value would be an array, with each element being the resolved value of the corresponding input promise. So in our case, the resolved value will be an array of image URLs.

Here's the full code

(async function () {
  resetTime();
  try {
    const photo1 = fetchImage(700, 800);
    const photo2 = fetchImage(50, 50);
    const photo3 = fetchImage(100, 200);

    const allPromises = await Promise.all([photo1, photo2, photo3]);
  } catch (err) {
    console.log(err);
  }
  elapsedTime('parallel');
})();

Here's the sandbox with the updated code

Here's how the network tab looks like when I run the code locally

I tested both versions of the code locally - with and without parallel promises, and checked how long they took. Parallel promises consistently gave significantly better loading times, and were almost 50% faster.

When Not To Use

Although Promise.all() looks like a one-stop solution for performance improvements, it is important to note that this will only be achieved when used on independent promises.

Consider the case where a fetch request is used to receive a JSON response.

const fetchResponse = await fetch('randomJSONapi');
const jsonData = await fetchResponse.json();

Even though two await statements are used one after the other, here it is required that the first await gets settled before the second one is called.

Wrapping Things Up

Returning promises in parallel is definitely a great way to improve code performance. A similar method Promise.allSettled() has been introduced as part of ES2022, with the difference being that allSettled() will not reject if one of the input promises reject. Instead, it will return the fulfilled values of the resolved input promises.

I hope you got to learn something new today. Do let me know what you think as a comment or connect with me on Twitter. I'd love to chat!

Resources -

  1. Learn more about Promises in JavaScript

  2. MDN documentation

  3. Another great blog post

Did you find this article valuable?

Support Abin John by becoming a sponsor. Any amount is appreciated!