javascriptnode.jsasync-awaitpromise

What is the difference between async/await forEach and Promise.all + map


In the accepted answer to a similar question the answer states that a forEach call just throw a promise then exit. I think this should be the case as forEach returns undefined but why does the following code work?

const networkFunction = (callback) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(callback());
    }, 200);
  });
};

(async () => {
  const numbers = [0, 1, 2];
  // works in parallel
  numbers.forEach(async (num) => {
    await networkFunction(() => {
      console.log("For Each Function: Hello");
    });
  });
})();

And it works in parallel this is the output of time node main.js # main.js contains only the mentioned code

❯ time node main.js
For Each Function: Hello
For Each Function: Hello
For Each Function: Hello

________________________________________________________
Executed in  365.63 millis    fish           external
   usr time  126.02 millis  964.00 micros  125.05 millis
   sys time   36.68 millis  618.00 micros   36.06 millis

Solution

  • The O.P.'s question was asking for clarification on another StackOverflow question found here. For further reading, and many other great answers on this general topic, please take a look at the link.

    For Googlers who only saw the question title

    Don't use async/await with forEach. Either use a for-of loop, or use Promise.all() with array.map().

    If you have a general understanding on promises and async/await, the TL;DR on the differences between promise.all() + array.map() and .forEach(), is that it's impossible to await a forEach() itself. Yes, you can run tasks in parallel in .forEach() just like you can with .map(), but you can't wait for all of those parallel tasks to finish, then do something once they've all finished. The whole point of using .map() instead of .forEach() is so you can get a list of promises, collect them with Promise.all(), then await the whole thing. To see what I mean, just put a console.log('Finished') after a forEach(async () => ...), and you'll see the "finished" get logged out before everything has finished running in the .forEach() loop. My advice would be to just not use .forEach() with async logic (and really there's not a reason use ever use .forEach() anymore these days, as I explain further down).

    For those who need something a bit more in depth, the rest of this answer will dive into more detail, first giving a small review on promises, then showing an in-depth explanation on how these approaches behave differently and why .forEach() is always the inferior solution when it comes to async/await.

    A primer on promises and async/await

    For the purposes of this discussion, you just have to remember that a promise is a special object that's promising that some task is going to be completed at some point in the future. You can attach listeners to a promise via .then(), to get notified when the task is completed, and to receive the resolved value.

    An async function is simply a function that will always return a promise, no matter what. Even if you do async function doThing() { return 2 }, it's not going to return 2, it's going to return a promise that immediately resolves to the value 2. Note that an asynchronous function will always return a promise immediately, even if it takes a long time for the function to run. This is why it's called a "promise", it's promising that the function will eventually finish running, and if you want to be notified for when the function has finished, you can add an event listener to it, via .then() or await.

    await is special syntax that lets you pause execution of an async function until a promise resolves. await will only effect the function it's directly inside. Behind the scenes, await is simply adding a special event listener to the promise's .then(), so it can know when the promise resolves and what value it resolves with.

    async function fn1() {
      async function fn2() {
        await myPromise; // This pauses execution of fn2(), not fn1()!
      }
      ...
    }
    
    async function fn1() {
      function fn2() {
        await myPromise; // An error, because fn2() is not async.
      }
      ...
    }
    

    If you can get a good grasp of these principles, then you should be able to understand the next sections.

    for-of

    A for-of loop lets you execute your asynchronous tasks in serial, one after another. For example:

    const delays = [1000, 1400, 1200];
    
    // A function that will return a
    // promise that resolves after the specified
    // amount of time.
    const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
    
    async function main() {
      console.log('start');
      for (const delay of delays) {
        await wait(delay);
        console.log('Finished waiting for the delay ' + delay);
      }
      console.log('finish');
    }
    
    main();

    The await causes main() to pause for the specified delay, after which the loop continues, the console.log() executes, and the loop starts again with the next iteration, beginning a new delay.

    This one should hopefully be a little straightforwards.

    Promise.all() + array.map()

    The use of Promise.all() and array.map() together lets you effectively run many asynchronous tasks in parallel, for example, we can wait for many different delays to finish at the same time.

    const delays = [1000, 1400, 1200];
    
    // A function that will return a
    // promise that resolves after the specified
    // amount of time.
    const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
    
    async function main() {
      console.log('start');
      await Promise.all(delays.map(async delay => {
        await wait(delay);
        console.log('Finished waiting for the delay ' + delay);
      }))
      console.log('finish');
    }
    
    main();

    If you remember back to our quick primer on promises and async/await, you'll remember that await only effects the function it's directly inside, causing that function to pause. In this case, the await from await wait(delay) will not be causing main() to pause like it did in the previous example, instead, it's going to cause the callback being passed into delays.map() to pause, because that's the function it's directly inside.

    So, we have delays.map() which will call the supplied callback, once for each delay inside of the delays array. The callback is asynchronous, so it's always going to return a promise immediately. The callback will begin executing with different delay arguments, but will inevitably hit the await wait(delay) line, pausing execution of the callback.

    Because the callback for .map() returns a promise, delays.map() is going to return an array of promises, which Promise.all() will then receive, and will combine them together into one super promise that resolves when all of the promises in the array resolves. Now, we await the super promise returned by promise.all(). This await is inside main(), causing main() to pause until all of the provided promises resolve. Thus, we've created a bunch of independent asynchronous tasks inside of main(), let them all finish on their own time, and then paused the execution of main() itself until all of these tasks have finished.

    The problem with .forEach()

    First of all, you really don't need to use forEach() for anything. It came out before the for-of loop did, and for-of is simply better than forEach() in every way. for-of can run against any iterable, you can use break and continue with them, and, most importantly for this topic, await will work as expected in for-of but not in forEach(). Here's why:

    const delays = [1000, 1400, 1200];
    
    // A function that will return a
    // promise that resolves after the specified
    // amount of time.
    const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
    
    async function main() {
      console.log('start');
      delays.forEach(async delay => {
        await wait(delay);
        console.log('Finished waiting for the delay ' + delay);
      })
      console.log('finish');
    }
    
    main();

    First, you'll notice that .forEach() will cause the tasks to run in parallel instead of serial, just like with .map(). This is because the await inside of forEach() is only affecting the callback, not main(). So, when we run delays.forEach(), we call this asynchronous function for each delay in delays, kicking off a bunch of asynchronous tasks. The problem is that nothing will wait for the asynchronous tasks to finish, indeed, it's impossible to wait for them. The asynchronous callback is returning promises each time it gets called, but unlike .map(), .forEach() completely ignores the return value of its callback. .forEach() receives the promise, and then simply ignores it. This makes it impossible to group the promises together and await them all via Promise.all() like we did previously. Because of this, you'll notice that "finish" gets logged out immediately, since we're never causing main() to wait for these promises to finish. This is likely not what you want.


    A specific answer to the original question

    (a.k.a. the original answer)

    It works because the for loop still runs, and you still start a bunch of async tasks. The problem is that you're not awaiting them. Sure you use await within .forEach(), but that only causes the .forEach() callback to wait, it doesn't pause your outer function. You can see this if you put a console.log() at the end of your async IIFE, your console.log() will fire immediately before all of your requests have finished. If you had used Promise.all() instead, then that console.log() would fire after the requests have finished.

    Broken version:

    const networkFunction = (callback) => {
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve(callback());
        }, 200);
      });
    };
    
    (async () => {
      const numbers = [0, 1, 2];
      // works in parallel
      numbers.forEach(async (num) => {
        await networkFunction(() => {
          console.log("For Each Function: Hello");
        });
      });
      console.log('All requests finished!');
    })();

    Fixed version:

    const networkFunction = (callback) => {
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve(callback());
        }, 200);
      });
    };
    
    (async () => {
      const numbers = [0, 1, 2];
      // works in parallel
      await Promise.all(numbers.map(async (num) => {
        await networkFunction(() => {
          console.log("For Each Function: Hello");
        });
      }));
      console.log('All requests finished!');
    })();