Search code examples
javascriptasync-awaitpromise

What is the cause of this async bug and why is it fixed with a blocked scoped const variable


I've been reading through the Asynchronous Programming chapter of Eloquent JavaScript and came across this async bug problem. The solution offered here was to do a .join() at the end, but I came across another solution through using a blocked scoped const. The problem is I can't figure out why it fixes the bug, if someone could offer an explanation it would be much appreciated.

const resolveName = (fruitname) => {
  return new Promise((resolve) => {
    resolve(fruitname);
  });
};

async function countLetters(fruitList) {
  let result = "";

  await Promise.all(
    fruitList.map(async (fruit) => {
      result += fruit + ": " + (await resolveName(fruit)).length + "\n";

      // Fix: storing the string into a variable fixes this.
      // const s = fruit + ": " + (await resolveName(fruit)).length + "\n";
      // result += s;
    })
  );

  return result;
}

const arr = ["apple", "banana", "cherry"];
const p = countLetters(arr);
p.then((r) => console.log(r));

I've tried debugging and scouring SO for a solution. The expected and actual output is as below:

Expected: apple: 5 banana: 6 cherry: 6

Actual: cherry: 6

Here's a link to a runnable that reproduces the bug.


Solution

  • The main difference is to do with when result is read and used in both of your callbacks. In the first buggy code, it's read immediately when the .map() callback fires, which at that time it's an empty string, and so you end up concatenating to an empty string and only see the last result. With your second fixed code, you're reading the value of result after the async code has finished, allowing each of your callbacks to update the shared result value and see the latest value of result as each callback resumes its execution one by one for each async call that was made.


    For your buggy code, JavaScript evaluates the expression below from left to right:

    //       vvvvvvvvvvvvvvvvvvvvvv --- synchronous
    result = result + (fruit + ": " + (await resolveName(fruit)).length + "\n");
    // ^^^^^                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ --- aysnc
    

    Each iteration of your .map() runs synchronously until it hits the async call, where it suspends the current callback's exeuction. So in the above line, the concatenation of result + fruit + ": " occurs synchronously and so it uses the original empty value of result at the time the .map() callback runs. However, the entire concatenation from the above expression can't complete and update the result variable until the resolveName() promise has resolved and the suspended callback's execution resumes. Due to how JavaScript handles promise resolutions (via the microtask queue), this only occurs after all the synchronous map iterations have completed and .map() has returned, meaning that result is an empty string for all the map iterations when result += ... is processed.


    With your code, things a little different, as result isn't involved yet:

    const s = fruit + ": " + (await resolveName(fruit)).length + "\n";
    

    again, fruit + ": " is computed synchronously, but the remainder of the expression can't be computed until the await is processed (which only happens after all map iterations have occurred). So the callback gets suspended and the remaining code which uses and updates result (ie: result += s) is only computed and run after the Promises have resolved and been processed. Thus, as the promises for "apple", "banna", and "cherry" are resolved, their associated suspended callback tasks are processed one by one off the micro-task queue, allowing your callback functions to resume and update the shared result variable. So first, the callback for apple resumes and updates result, then the callback for "bananna" will resume and update the same result variable, and then lastly the callback for "cherry" will resume and update result.