Don't await in loops

arnaud

Arnaud

Posted on August 3, 2020

Don't await in loops

In software development, a very common task is to perform an operation on each element of an iterable:

for (let i = 0; i < array.length; i++) {
  work(array[i]);
}

When introducing asynchronous operations, it's easy to write:

for (let i = 0; i < array.length; i++) {
  const result = await work(array[i]);
  doSomething(result);
}

However, this is far from optimal as each successive operation will not start until the previous one has completed.

Imagine the work function takes 100ms more than its previous execution:

const work = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

for (let i = 0; i < 10; i += 1) {
  await work((i + 1) * 100);
}

The first call will take 100ms, the second 200ms, and so on. The entire loop will be equal to the sum of the duration of each asynchronous operation, about 5.5 seconds!

Since work is asynchronous, we can use Promise.all to execute all the operations in parallel. All we need to do is add the promises to an array and call Promise.all

const p = [];
for (let i = 0; i < 10; i += 1) {
  p.push(work((i + 1) * 100));
}
const results = await Promise.all(p);

Now the entire loop will be as slow as its slowest operation, in our case this is ~1 second, 5.5x faster!

However, Promise.all does have one problem. It will reject immediately upon any of the input promises rejecting.

try {
  const p = [
    new Promise((resolve, reject) => setTimeout(() => reject(new Error('Failure!')), 200)),
  ];
  for (let i = 0; i < 10; i += 1) {
    p.push(work((i + 1) * 100));
  }
  const results = await Promise.all(p);
  console.log(results); // <- never executed !
} catch (error) {
  console.error(error.message); // Error: Failure!
}

There is only one little change needed to get all promises to run. We need to add a catch to each promise to handle the failures of the failed promises, and let the other promises resolve.

const errorHandler = (e) => e;

const p = [
  new Promise((resolve, reject) => {
    setTimeout(() => reject(new Error('Failure!')), 200);
  }).catch(errorHandler),
];
for (let i = 0; i < 10; i += 1) {
  p.push(work((i + 1) * 100).catch(errorHandler));
}
const results = await Promise.all(p);

results.forEach((result, i) => {
  if (result instanceof Error) {
    console.log(`Call ${i} failed`);
    return;
  }
  console.log(`Call ${i} succeeded`);
});

Notice how a new Error is passed to the reject callback. The handler simply returns the error, but this would also be a good place to log it. If in the handler the error were thrown instead, then we would default back to the previous behavior where the catch block would be entered.

We should always do our best to execute asynchronous operations in parallel, and we've seen that with proper error handling, it is possible to use Promise.all to execute a bunch of promises in parallel, even if one fails.

Happy coding!

💖 💪 🙅 🚩
arnaud
Arnaud

Posted on August 3, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

What was your win this week?
weeklyretro What was your win this week?

November 29, 2024

Where GitOps Meets ClickOps
devops Where GitOps Meets ClickOps

November 29, 2024

How to Use KitOps with MLflow
beginners How to Use KitOps with MLflow

November 29, 2024

Modern C++ for LeetCode 🧑‍💻🚀
leetcode Modern C++ for LeetCode 🧑‍💻🚀

November 29, 2024