Search code examples
javascriptasync-awaites6-promiseevent-loop

Why does an async function finishes executing after a promise started later?


This is something about the event loop I don't understand.

Here's the code:

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}
 
async function async2() {
  console.log('async2 start');
  return new Promise((resolve, reject) => {
    resolve();
    console.log('async2 promise');
  })
}
 
console.log('script start');
 
setTimeout(function() {
  console.log('setTimeout');
}, 0);
 
async1();
 
new Promise(function(resolve) {
  console.log('promise1');
  resolve();
}).then(function() {
  console.log('promise2');
}).then(function() {
  console.log('promise3');
});
 
console.log('script end');

the result is:

script start
async1 start
async2 start
async2 promise
promise1
script end
promise2
promise3
async1 end
setTimeout

I can't understand why async1 end is printed after promise 2 and promise 3.

What happens in the event loop that explains this? In what order are these microtasks pushed and popped in the queue?


Solution

  • You're surprised why async1 end comes after promise2 and promise3, although it was called before them, and microtasks are executed in the order they are enqueued.

    However, it really boils down to how many microtasks does it take for the async function to resolve.

    Take a look at this (it's the same code but with 4 raw promises):

    async function async1() {
      console.log('async1 start');
      await async2();
      console.log('async1 end');
    }
    
    async function async2() {
      console.log('async2 start');
      return new Promise((resolve, reject) => {
        resolve();
        console.log('async2 promise');
      })
    }
    
    console.log('script start');
    
    setTimeout(function() {
      console.log('setTimeout');
    }, 0);
    
    async1();
    
    new Promise(function(resolve) {
      console.log('promise1');
      resolve();
    }).then(function() {
      console.log('promise2');
    }).then(function() {
      console.log('promise3');
    }).then(function() {
      console.log('promise4');
    });
    
    console.log('script end');
    /* Just to make the console fill the available space */
    .as-console-wrapper {
      max-height: 100% !important;
    }

    Whoops, async1 end is no longer at the end: it comes before promise4!

    So what does this tell us? It means that async1 end is logged after 3 microtasks (not counting the ones caused by promiseN's).

    What takes those 3 microtasks? Let's inspect:

    The last one is obvious: the await operator in async1 consumes one.

    We have two left.

    To see that, in async2, instead of creating a promise through the Promise constructor, create a thenable (an object with a .then() method, aka. promise-like object), that acts the same (well, a real promise is much more complex, but for the sake of this example, it works this way). It'd look like this:

    async function async1() {
      console.log('async1 start');
      await async2();
      console.log('async1 end');
    }
     
    async function async2() {
      console.log('async2 start');
      
      console.log('async2 promise');
      return {
        then(resolve, reject){
          queueMicrotask(() => {
            resolve();
            console.log('async2 resolve');
          });
          return Promise.resolve()
        }
      };
    }
     
    console.log('script start');
     
    setTimeout(function() {
      console.log('setTimeout');
    }, 0);
     
    async1();
     
    new Promise(function(resolve) {
      console.log('promise1');
      resolve();
    }).then(function() {
      console.log('promise2');
    }).then(function() {
      console.log('promise3');
    }).then(function() {
      console.log('promise4');
    });
     
    console.log('script end');
    /* Just to make the console fill the available space */
    .as-console-wrapper {
      max-height: 100% !important;
    }

    But, you may see that something's still wrong. promise2 is still called before async2 resolve.

    async functions return a promise before their return statement is reached. That makes sense, but that also means, that they can't return the same promise object that is passed to return. They have to await the returned promise too, in order to make their returned promise mirror its state.

    So, let's see when is our custom then function called:

    async function async1() {
      console.log('async1 start');
      await async2();
      console.log('async1 end');
    }
     
    async function async2() {
      console.log('async2 start');
      
      console.log('async2 promise');
      return {
        then(resolve, reject){
          console.log('async2 then awaited')
          queueMicrotask(() => {
            resolve();
            console.log('async2 resolve');
          });
        }
      };
    }
     
    console.log('script start');
     
    setTimeout(function() {
      console.log('setTimeout');
    }, 0);
     
    async1();
     
    new Promise(function(resolve) {
      console.log('promise1');
      resolve();
    }).then(function() {
      console.log('promise2');
    }).then(function() {
      console.log('promise3');
    }).then(function() {
      console.log('promise4');
    });
     
    console.log('script end');
    /* Just to make the console fill the available space */
    .as-console-wrapper {
      max-height: 100% !important;
    }

    Ah ha, in a new microtask!


    We found all the holes, so now we can see how the async thingies execute interlaced with the promises (here --> means enqueues, <-- means logs; microtasks are marked with μt):

    MACROTASK #0
    
    <-- script start
    
        setTimeout enqueued, but it creates a MACROTASK, so it always comes at last --> MT#1
    
        async1 called
    <-- async1 start
        async2 called
    <-- async2 start
        promise executor called synchronously
    <-- async2 promise
        resolved promise returned to async2
        async2 execution halted --> μt#1
        async1 execution halted at await
        
        promise executor called synchronously
    <-- promise1
        promise1 resolved --> μt#2
        `then` chain built
                                     
    <-- script end            
    
    microtask #1
        async2 continues, calls `then` of the returned promise
    <-- async2 `then` awaited
        promise's `then` enqueues microtask for calling callback of async2 --> μt#3
    
    microtask #2
        promise2 `then` called
    <-- promise2
        promise2 resolved --> μt#4
    
    microtask #3
        called queued callback of promise
    <-- async2 resolve
        async2 completes
        promise returned by async2 resolves --> μt#5
    
    microtask #4
        promise3 `then` called
    <-- promise3
        promise3 resolved --> μt#6
    
    microtask #5
        async1 continues
    <-- async1 end
        async1 completes
    
    microtask #6
        promise4 `then` called
    <-- promise4
        promise4 resolved
    
    MACROTASK #1
    
        timer callback called
    <-- setTimeout