Search code examples
javascriptasync-awaitevent-loop

Async function with +=


let x = 0;

async function test() {
    x += await 5;
    console.log('x :', x);
}

test();
x += 1;
console.log('x :', x);

The values of x logged are 1 and 5. My question is: why is the value of x 5 on second log?

If the test is executed after x += 1 (since it is an async function) then the value of x is 1 by the time test is executed, so x += await 5 should make the value of x 6.


Solution

  • TL;DR: Because += reads x before, but writes it after it has changed, due to the await keyword in its second operand (right-hand side).


    async functions run synchronously when they are called until the first await statement.

    So, if you remove await, it behaves like a normal function (with the exception that it still returns a Promise).

    In that case, you get 5 (from the function) and 6 (from the main script) in the console:

    let x = 0;
    
    async function test() {
      x += 5;
      console.log('x :', x);
    }
    
    test();
    x += 1;
    console.log('x :', x);


    The first await stops synchronous running, even if its argument is an already resolved promise (or as in here, not a promise at all - these will be converted to resolved promises by await), so the following will return 1 (from the main script) and 6 (from the function), as you expected:

    let x = 0;
    
    async function test() {
      // Enter asynchrony
      await 0;
    
      x += 5;
      console.log('x :', x);
    }
    
    test();
    x += 1;
    console.log('x :', x);


    However, your case is a bit more complicated.

    You've put await inside an expression that uses +=.

    You probably know that in JS x += y is identical to x = (x + y) (unless x is an expression with side-effects, which isn't the case here). I'll use the latter form for to make it easier to understand:

    let x = 0;
    
    async function test() {
      x = (x + await 5);
      console.log('x :', x);
    }
    
    test();
    x += 1;
    console.log('x :', x);

    When the interpreter reaches this line...

    x = (x + await 5);
    

    ...it starts evaluating it, substitutes x, so it turns to...

    x = (0 + await 5);
    

    ...then, it evaluates the expression inside await (5), turns it into a resolved promise, and starts waiting for it.

    The code after the function call starts to run, and modifies the value of x (from 0 to 1), then logs it.

    x is now 1.

    Then, after the main script finishes, the interpreter goes back to the paused test function, and continues evaluating the line, which, with the await out of the way, looks like this:

    x = (0 + 5);
    

    And, since the value of x has already been substituted, it remains 0.

    Finally, the interpreter does the addition, stores 5 to x, and logs it.

    You can check this behaviour by logging inside an object property getter/setter (in this example, y.z, which reflects the value of x:

    let x = 0;
    const y = {
      get z() {
        console.log('get x :', x);
        console.log(new Error().stack.replace('Error', 'Stacktrace')); //Log stacktrace using an Error object
        return x;
      },
      set z(value) {
        console.log('set x =', value);
        console.log(new Error().stack.replace('Error', 'Stacktrace')); //Log stacktrace using an Error object
        x = value;
      }
    };
    
    async function test() {
      console.log('inside async function');
      y.z += await 5;
      console.log('x :', x);
    }
    
    test();
    console.log('main script');
    y.z += 1;
    console.log('x :', x);
    console.log('end of main script')
    
    /* Output:
    
    inside async function
    get x : 0 <-------------- async fn reads
    Stacktrace
        at Object.get z [as z] (https://stacksnippets.net/js:19:17)
        at test (https://stacksnippets.net/js:31:3) <-- async fn is synchronous here
        at https://stacksnippets.net/js:35:1 <--------- (main script is still in the stack)
    
    main script
    get x : 0
    Stacktrace
        at Object.get z [as z] (https://stacksnippets.net/js:19:17)
        at https://stacksnippets.net/js:37:1
    set x = 1
    Stacktrace
        at Object.set z [as z] (https://stacksnippets.net/js:24:17)
        at https://stacksnippets.net/js:37:5
    x : 1
    end of main script
    
    set x = 5 <-------------- async fn writes
    Stacktrace
        at Object.set z [as z] (https://stacksnippets.net/js:24:17)
        at test (https://stacksnippets.net/js:31:7) <-- async fn is asynchronous (main script is no longer in the stack)
    x : 5 <------------------ async fn logs
    
    */
    /* Just to make console fill the available space */
    .as-console-wrapper {
      max-height: 100% !important;
    }