Pull Requests (PR) when used properly are a good place to share knowledge between members of a codebase.

Recently, in several PRs that I got a chance to review, at first glance the code appeared to be doing something, but after a closer look in fact it was doing something slightly different of what it was expected.

The typescript code in question was using the forEach high order function1 of the Array object.

So, is there a problem in using the forEach function?

It depends. How are you using the function?

Let’s take a look at a simple use case.2

// example 1
function main () {
    console.log('starting to log elements');
    [2, 3, 5].forEach(logElementSync);
    console.log('completed logging elements');
}

function logElementSync(element, index) {
    console.log(`\tposition ${index} contains element with value ${element}`);
}

main();

The code is pretty straightforward, it’s a simple use case where the code is logging before and after iterating over the array, and also while it iterates over the array it logs the details of each element in the array.

Taking a look at the output, you can confirm the behavior described above.

output of example 1:

starting to log elements
    position 0 contains element with value 2
    position 1 contains element with value 3
    position 2 contains element with value 5
completed logging elements

Now… what if?.. and what if you used the forEach function with an asynchronous callback3?

Let’s take a look at another example, but this time using an asynchronous (async) callback function.

// example 2
function main () {
	console.log('starting to log elements');
	[2, 3, 5].forEach(logElementASync);
	console.log('completed logging elements');
}

// function to simulation async work
function waitSecondsAndLog (seconds, element, index) {
	return new Promise((resolve, reject) => {
		setTimeout(() => {
			console.log(`\tposition ${index} contains element with value ${element} -> resolved after ${seconds} seconds`);
			resolve();
		}, seconds * 1000); // 1000 milliseconds = 1 second
	});
}
async function logElementASync(element, index) {
	await waitSecondsAndLog(2, element, index);
	console.log(`\t\tsuccessfully logged element in position ${index}`);
}

main();

Looking at the code of example 2, it appears it’s doing and behaving in a very similar way to the code in `example 1. So, isn’t it just waiting 2 seconds before logging each element in the array and at the end logging a final string?

I mean.. At least it’s what the code mainly expresses.

However, after you take a look at the output, will you continue thinking the same?

output of example 2:

starting to log elements
completed logging elements
    position 0 contains element with value 2 -> resolved after 2 seconds
        successfully logged element in position 0
    position 1 contains element with value 3 -> resolved after 2 seconds
        successfully logged element in position 1
    position 2 contains element with value 5 -> resolved after 2 seconds
        successfully logged element in position 2

Is this a bug? F@*#, what happened???

Nop. It’s not a bug.

You probably just assumed the software would work for the additional async scenario you were thinking.

But, as we all know, in the software world, there is no magic. Stuff just doesn’t happen, unless we explicitly tell the computer to. Until it does… and until it does not. 😅

How could I have known that?

Whenever you need to interact with a piece of software, not written by you, it’s a good practice to take some time to read the technical documentation of the software in question.

If you are lucky enough, there will be documentation, it will be up-to-date, and it will also be informative!!!

Taken from MDN Web Docs Array forEach Docs4

Note: forEach expects a synchronous function.

forEach does not wait for promises. Make sure you are aware of the implications while using promises
(or async functions) as forEach callback.

It’s worth mentioning, the official documentation of javascript, based on ECMAScript specification, is defined by Ecma International’s TC39. However, reading the official documentation of forEach, it is not mentioned if forEach supports async callbacks or not. Which leaves the developer to find out for himself. Which it’s not a great solution because developers start making assumptions, and in this particular they don’t live to the expectations.

So, can or can’t I use an async function within a forEach function?

You definitely can, because each async function will in fact be executed.

However, I would argue the relevant question is:

Should or shouldn’t I use an async function within a forEach function?

The answer to this question is, it depends.

As the creator of Linux once said,

“‘It depends’ is almost always the right answer in any big question.” - Linus Torvalds

In fact, it really depends. But, as a rule of thumb, an async function shouldn’t be used within a forEach function.5

So, perhaps to help make a decision on whether to use an async function within forEach, answer the following questions:

  • Do I care about the completion of the async code?
  • Is it ok, if the code calling the forEach function, to continue the execution without waiting for the completion/rejection of the async code?

So, since the forEach function does not support async functions, when you call the forEach with an async callback, what you are saying is the following. You only care about the completion of the async code inside the async function, but not on the code that triggered the execution. Meaning, on the code that initiated the execution of the forEach, you don’t really care about the async code, you know it will eventually be completed, but you want the main code execution to continue and not being blocked by the async code.

I really, really want to wait for my async forEach, what can I do?

Ok, you have options.

  1. Leverage the Promise API, by using Promise.all and replacing the Array forEach function with map.
  2. Use control statements like while or for loop ( for…in, for…of, for await…of ).

Leverage the Promise API, by using Promise.all and replacing the Array forEach function with map

Since the Array map function returns a value, you can use this in your favor. When map receives an async function it will automatically return a Promise.

However, just replacing the forEach function with map is not enough, but it’s a step in the right direction.

To make sure the code really waits for every promise to be resolved, before continuing its execution, it’s necessary to wait for all the promises returned by the map function. To wait for the promises, you just need to use Promise.all.

// example 3
async function main () {
	console.log('starting to log elements');
	await Promise.all([2, 3, 5].map(logElementASync));
	console.log('completed logging elements');
}

// function to simulation async work
function waitSecondsAndLog (seconds, element, index) {
	return new Promise((resolve, reject) => {
		setTimeout(() => {
			console.log(`\tposition ${index} contains element with value ${element} -> resolved after ${seconds} seconds`);
			resolve();
		}, seconds * 1000); // 1000 milliseconds = 1 second
	});
}
async function logElementASync(element, index) {
	await waitSecondsAndLog(2, element, index);
	console.log(`\t\tsuccessfully logged element in position ${index}`);
}

main();

Taking a look at the output of the new strategy.

output of example 3:

starting to log elements
    position 0 contains element with value 2 -> resolved after 2 seconds
        successfully logged element in position 0
    position 1 contains element with value 3 -> resolved after 2 seconds
        successfully logged element in position 1
    position 2 contains element with value 5 -> resolved after 2 seconds
        successfully logged element in position 2
completed logging elements

So, as the output of example 3 shows, using Promise.all with map function it’s making the main code to wait for the completion/resolution of every async function of each element in the array. So, the logs indicate the log completed logging elements was in fact only printed once all the elements in the array were processed.

Is this all? Or is there any catch?

When an async callback is executed, and this applies at least for the map and forEach functions, the callbacks are executed in parallel. So, by following this strategy it’s not possible to guarantee the order of completion.

However, you can breathe, this is only relevant if you really need to guarantee the functions are executed and return the values in sequential order.

The next code sample demonstrates that the order of completion is not sequential, it depends on the time the promise will take to be resolved.

// example 4
async function main () {

	console.log('starting to log elements');
	await Promise.all([2, 3, 5, 7, 11].map(logElementASync));
	console.log('completed logging elements');
}

// function to simulation async work
function waitSecondsAndLog (seconds, element, index) {
	return new Promise((resolve, reject) => {
		setTimeout(() => {
			console.log(`\tposition ${index} contains element with value ${element} -> resolved after ${seconds} seconds`);
			resolve();
		}, seconds * 1000); // 1000 milliseconds = 1 second
	});
}
async function logElementASync(element, index) {
	await waitSecondsAndLog(Math.random() + 1, element, index);
	console.log(`\t\tsuccessfully logged element in position ${index}`);
}

main();
possible output of example 4:

starting to log elements
    position 4 contains element with value 11 -> resolved after 1.3931866206049484 seconds
        successfully logged element in position 4
    position 1 contains element with value 3 -> resolved after 1.6460489240414948 seconds
        successfully logged element in position 1
    position 0 contains element with value 2 -> resolved after 1.6859511215520768 seconds
        successfully logged element in position 0
    position 3 contains element with value 7 -> resolved after 1.700864754428197 seconds
        successfully logged element in position 3
    position 2 contains element with value 5 -> resolved after 1.7248640895881826 seconds
        successfully logged element in position 2
completed logging elements

To mitigate this situation, you should read the next strategy.

Use control statements like while or for loop

Using control statements like while of for loop, means there is no high order abstractions on top of the iterators. So, it’s possible to wait for async code inside this control statements.

So, the following code sample will execute and wait for the processing of each element in the array, sequentially.

// example 5
async function main () {

	console.log('starting to log elements');

	const elements = [2, 3, 5];
	for (let i = 0; i < elements.length; i++) {
		await logElementASync(elements[i], i);
	}
	console.log('completed logging elements');
}

// function to simulation async work
function waitSecondsAndLog (seconds, element, index) {
	return new Promise((resolve, reject) => {
		setTimeout(() => {
			console.log(`\tposition ${index} contains element with value ${element} -> resolved after ${seconds} seconds`);
			resolve();
		}, seconds * 1000); // 1000 milliseconds = 1 second
	});
}
async function logElementASync(element, index) {
	await waitSecondsAndLog(Math.random() + 1, element, index);
	console.log(`\t\tsuccessfully logged element in position ${index}`);
}

main();

Take a closer look on the order of the output provided by the code of example 5.

possible output of example 5:
 
starting to log elements
    position 0 contains element with value 2 -> resolved after 1.762111532296442 seconds
		successfully logged element in position 0
	position 1 contains element with value 3 -> resolved after 1.5403082758542372 seconds
		successfully logged element in position 1
	position 2 contains element with value 5 -> resolved after 1.8082571487735106 seconds
		successfully logged element in position 2
completed logging elements

So, following this latest strategy, the main code is waiting for the completion of all the async functions, and it’s also guarantying each element in the array is only being processed if the previous one was already processed. This also makes the code blocking. The main code execution will not continue until all the async functions are executed for every element in the array.

Take Away - TL;DR

Using forEach with an async function as argument will behave differently if instead it receives a synchronous function as argument. When using forEach and async callbacks, the functions will be executed, but the main code will not wait for the completion of the promises, so the main execution will not be blocked (non-blocking).

However, if you really want to wait for the promises to resolve, before continuing, you can use Promise.all with the high order map function of the Array object. Also, it’s worth mentioning that it’s not guaranteed the order of completion of the async functions. To mitigate that, you can use for loops.

So, to conclude, as a rule of thumb, an async function shouldn’t be used within a forEach function. Only, if you really know and understand what you are doing. Or not… you are the master of your own ship. 😁


References


  1. High order function is a function that receives or returns a function. ↩︎

  2. All the code samples should be able to run on the browser console. ↩︎

  3. A callback function is a function that is passed to another function, and it’s invoked inside the function that received the function as argument. ↩︎

  4. MDN Web Docs is a web platform that provides information, in-depth documentation, about open web technologies. As they advertise, resources for developers, by developers. ↩︎

  5. The same can be said for the other high order functions of the Array object. ↩︎