10 minutes
Is my code really waiting for my async function?
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.
- Leverage the Promise API, by using
Promise.all
and replacing the ArrayforEach
function withmap
. - Use control statements like
while
orfor
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
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach
- https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/for...in
- https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/for...of
- https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/for-await...of
- https://stackoverflow.com/questions/37576685/using-async-await-with-a-foreach-loop
- https://tc39.es/
- https://tc39.es/ecma262/multipage/indexed-collections.html#sec-array.prototype.foreach
High order function is a function that receives or returns a function. ↩︎
All the code samples should be able to run on the browser console. ↩︎
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. ↩︎
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. ↩︎
The same can be said for the other high order functions of the Array object. ↩︎
javascript typescript asynchronous synchronous forEach Promise.all forEach not waiting
2127 Words
02-26-2022 15:00 (Last updated: 02-27-2022 17:14)
6723791 @ 02-27-2022