DEV Community

Antony Garand
Antony Garand

Posted on

JavaScript: Async math is hard

Challenge

While we're glad ES7 brings us async and await, asynchronous code still isn't as straightforward as it could be.
Try guessing what the following snippet should return, then head up to the writeup!

function sleepOneSecondAndReturnTwo() {
    return new Promise(resolve =>  {
        setTimeout(() => { resolve(2); }, 1000);
    });
}

let x = 0;

async function incrementXInOneSecond() {
    x += await sleepOneSecondAndReturnTwo();
    console.log(x);
}

incrementXInOneSecond();
x++;
console.log(x);

This can be simplified quite a bit due to how asynchronous code is handled within JavaScript.

The setTimeout and creation of a new function is not necessary, as the asynchronous part of the execution will be delayed even if there is no delay in the promise resolution.

await will also convert non-promises to resolved promise, as described on MDN's await page

If the value of the expression following the await operator is not a Promise, it's converted to a resolved Promise.

await 2 is therefore the shorthand syntax of await Promise.resolve(2);.

This leads us to the following code:

let x = 0;

async function incrementX() {
    x += await 2;
    console.log(x);
}

incrementX();
x++;
console.log(x);

Writeup

Let me preface this by giving out the inspiration of this post, which is this great video by Jake Archibald.
I found the content so interesting I'm writing about it here, but all credits goes to Jake!

Answer

Here is the short version of the previous challenge:

let x = 0;

async function incrementX() {
    x += await 2;
    console.log(x);
}

incrementX();
x++;
console.log(x);

As you may have found out, the output of this script is 1 and 2, instead of the 1 and 3 we could expect.

Let's look at how the synchronous part of the code will be executed:

let x = 0;

Quite easy, x = 0!

Now, inside the async function, things gets interesting.
For an easier visualisation, I will expand the addition assignment to its full form, as it primarly is syntastic sugar:

x += await 2;

Becomes

x = x + await 2;

As we are in an asynchronous function, once we reach the await statement, we will change our execution context.
A copy of the runningContext will be created, named asyncContext.
When the execution of our async function will resume, this context will be used instead of the currently running context.

This is the behavior defined in the EcmaScript spec when running an asynchronous function.

Execution Contexts creation

Since we are now awaiting a variable, the remaining content of the function will not be executed until the promise is resolved, and the execution stack is empty.

Execution stack

We will therefore continue with the synchronous execution of the code.

x++;

x is now 1!

The previous value of X was 0 in the running execution stack, therefore it gets incremented to 1.

Running context value - X = 1

console.log(x)

Print 1 into the console

Our current execution is completed, therefore we can now get back to the asynchronous execution.

await 2 is the shorthand syntax of await Promise.resolve(2), which immediatly gets resolved.

The async execution context still has x with its previous value of 0, so the following code gets executed:

x = x + 2;

Which is the same as the following, in our current execution context:

x = 0 + 2;

Async execution context values - X = 0 + 2

The async execution context now has X with a value of 2.

Async execution context values - X = 2

Finally, as we now enter a new block of synchronous code, both execution contexts will now merge, the running execution context acquiring x's new value of 2.
Execution contexts merging

console.log(x)

2 Is finally printed into the console.

Real World

What does this mean for us, developers?

The content of this post may seem like esoteric knowledge, but it was actually initially found with a real scenario.
This reddit post has a snippet which can be summarized with the following:

let sum = 0;

function addSum() {
    [1,2,3,4,5].forEach(async value => {
        sum += await value;
        console.log(sum);
    });
}

addSum();

setTimeout(() => { console.log(sum); }, 1000);

As you probably know, the output of the following code will be 1, 2,3,4,5, and finally after one second, 5.
Removing the await keyword instead returns 15, which is odd behavior if we're not familiar with the content of this post.

Replace await value with await getSomeAsynchronousValueFromAnApi() and you get a real world scenario in which hours of debugging and head scratching would most likely have been required!

Solutions

There are many workarounds possible to prevent this from happening, here are few of those.

Here is the original code I will replace:

x += await 2;

Solution 1: Awaiting in a variable

const result = await 2;
x += result;

With this solution, the execution contexts will not share the x variable, and therefore it will not be merged.

Solution 2: Adding to X after awaiting

x = await 2 + x;

This solution is still error-prone if there are multiple await statements in the operation, but it does prevent the overwriting of X in multiple execution contexts!

Conclusion

Await is great, but you can't expect it to behave like synchronous code!

Unit tests and good coding practices would help preventing those odd scenarios from reaching a production environment.

Please do write comments with your different workarounds and best practices around this behavior, I'd love to have your opinion on the subject!

References

EcmaScript:

Youtube: JS quiz: async function execution order
Reddit: Original inspiration of this post

Original on Gitlab

Top comments (2)

Collapse
 
integrii profile image
Eric Greer

Do you think you'll ever look back at this post and ask yourself why in the hell you're using JavaScript?

Collapse
 
rollandvaillancourt profile image
Rolland-Vaillancourt

Really interesting post, thanks for sharing.