Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Hidden Implementation Details of JavaScript async/await You May Not Know

Tech 1

To test your understanding of async/await behavior in the JavaScript event loop, try these two similar snippets first:

async function runFirstTask() {
  await new Promise((resolve, reject) => {
    resolve();
  });
  console.log('A');
}

runFirstTask();

new Promise((resolve) => {
  console.log('B');
  resolve();
}).then(() => {
  console.log('C');
}).then(() => {
  console.log('D');
});

// Output: B A C D

Second snippet:

async function runFirstTask() {
  await runSecondTask();
  console.log('A');
}

async function runSecondTask() {
  return new Promise((resolve, reject) => {
    resolve();
  });
}

runFirstTask();

new Promise((resolve) => {
  console.log('B');
  resolve();
}).then(() => {
  console.log('C');
}).then(() => {
  console.log('D');
});

// Output: B C D A

Almost identical code, different output. To understand why, we break down the subtle implementation details below.

Async Function Return Value Handling

Before discussing await, we first cover how async functions process return values. Similar to Promise.prototype.then, async adjusts its hendling based on the type of the returned value, which leads to different numbers of microtasks being queued.

The core rule is: when an async function returns a value, it schedules a different number of microtasks based on the return type:

  • Return non-thenable, non-Promise value: no additional waiting (0 extra microtasks)
  • Return a thenable object: wait for 1 additional microtask tick
  • Return a native Promise: wait for 2 additional microtask ticks

Let's verify this with examples:

Example 1 (non-Promise, non-thenable return):

async function testNonPromise() {
  return 1;
}

testNonPromise().then(() => console.log(1));
Promise.resolve()
  .then(() => console.log(2))
  .then(() => console.log(3));

// Output: 1 2 3

Example 2 (thenable return):

async function testThenable() {
  return {
    then(cb) {
      cb();
    }
  };
}

testThenable().then(() => console.log(1));
Promise.resolve()
  .then(() => console.log(2))
  .then(() => console.log(3));

// Output: 2 1 3

Example 3 (native Promise return):

async function testNativePromise() {
  return new Promise((resolve, reject) => {
    resolve();
  });
}

testNativePromise().then(() => console.log(1));
Promise.resolve()
  .then(() => console.log(2))
  .then(() => console.log(3))
  .then(() => console.log(4));

// Output: 2 3 1 4

Let's test this rule on a classic interview question:

async function taskA() {
  console.log('1');
  await taskB();
  console.log('AAA');
}

async function taskB() {
  console.log('3');
  return new Promise((resolve, reject) => {
    resolve();
    console.log('4');
  });
}

console.log('5');

setTimeout(() => {
  console.log('6');
}, 0);

taskA();

new Promise((resolve) => {
  console.log('7');
  resolve();
}).then(() => {
  console.log('8');
}).then(() => {
  console.log('9');
}).then(() => {
  console.log('10');
});

console.log('11');

// Output: 5 1 3 4 7 11 8 9 AAA 10 6

Step-by-step execution breakdown:

  1. Synchronous code runs first, outputs 5
  2. setTimeout is added to the macrotask queue
  3. Execute taskA, outputs 1
  4. Execute taskB, outputs 3
  5. Promise executor runs synchronously, outputs 4. Since taskB returns a native Promise, we need to wait 2 microtask ticks before resuming execution of taskA, so AAA is not output yet
  6. Exit taskA, continue synchronous execution, output 7
  7. Finish synchronous code, output 11
  8. Process first microtask: output 8
  9. Process second microtask: output 9
  10. Two microtask ticks have passed, resume taskA and output AAA
  11. Process the last queued microtask: output 10
  12. Process the macrotask from setTimeout: output 6

Behavior Differences Based on await Right-hand Value Type

Non-thenable Values

When await receives a non-thenable value, it immediately adds a continuation microtask to the queue, with no additional waiting:

async function demo() {
  console.log(1);
  const result = await 1;
  console.log(2);
}

demo();
console.log(3);
// Output: 1 3 2
function logTwo() {
  console.log(2);
}

async function demo() {
  console.log(1);
  await logTwo();
  console.log(3);
}

demo();
console.log(4);
// Output: 1 2 4 3
async function demo() {
  console.log(1);
  await 123;
  console.log(2);
}

demo();
console.log(3);

Promise.resolve()
  .then(() => console.log(4))
  .then(() => console.log(5))
  .then(() => console.log(6))
  .then(() => console.log(7));

// Output: 1 3 2 4 5 6 7

Thenable Objects

When await receives a thenable object, it needs to wait for one full microtask tick before executing the continuation code:

async function demo() {
  console.log(1);
  await {
    then(cb) {
      cb();
    }
  };
  console.log(2);
}

demo();
console.log(3);

Promise.resolve()
  .then(() => console.log(4))
  .then(() => console.log(5))
  .then(() => console.log(6))
  .then(() => console.log(7));

// Output: 1 3 4 2 5 6 7

Native Promise Values

For already resolved native Promises, the behavior matches non-thenable values. This is because TC39, the ECMAScript standards body, modified the specification to remove the extra two microtask waits that existed in older implementations. This change significantly optimizes the speed of await execution, and only applies to native Promises, not to generic thenables.

async function demo() {
  console.log(1);
  await new Promise((resolve, reject) => {
    resolve();
  });
  console.log(2);
}

demo();
console.log(3);

Promise.resolve()
  .then(() => console.log(4))
  .then(() => console.log(5))
  .then(() => console.log(6))
  .then(() => console.log(7));

// Output: 1 3 2 4 5 6 7

The example below demonstrates how nested await behaves, and confirms that async/await uses synchronous-style syntax to schedule asynchronous microtasks:

async function innerFunc() {
  console.log(1);
  await 1;
  console.log(2);
  await 2;
  console.log(3);
  await 3;
  console.log(4);
}

async function outerFunc() {
  console.log(5);
  await innerFunc();
  console.log(6);
}

outerFunc();
console.log(7);

Promise.resolve()
  .then(() => console.log(8))
  .then(() => console.log(9))
  .then(() => console.log(10))
  .then(() => console.log(11));

// Output: 5 1 7 2 8 3 9 4 10 6 11

This output is equivalent to all of the implemantations below, which confirms that await can be modeled as chaining then calls:

async function outerFunc() {
  console.log(5);
  console.log(1);
  await 1;
  console.log(2);
  await 2;
  console.log(3);
  await 3;
  console.log(4);
  await null;
  console.log(6);
}

outerFunc();
console.log(7);
// Output identical to above
async function outerFunc() {
  console.log(5);
  console.log(1);
  Promise.resolve()
    .then(() => console.log(2))
    .then(() => console.log(3))
    .then(() => console.log(4))
    .then(() => console.log(6));
}

outerFunc();
console.log(7);
// Output identical

Additional Examples to Verify the Rules

Example 1

async function taskNoReturn() {
  new Promise((resolve, reject) => {
    resolve();
  });
}

async function taskPromiseReturn() {
  return new Promise((resolve, reject) => {
    resolve();
  });
}

async function mainTask() {
  // Uncomment one option at a time to test:
  // Option 1 (output: B A C D)
  // await new Promise((resolve, reject) => { resolve(); })

  // Option 2 (output: B A C D)
  // await taskNoReturn()

  // Option 3 (output: B C D A)
  await taskPromiseReturn()

  console.log('A');
}

mainTask();

new Promise((resolve) => {
  console.log('B');
  resolve();
}).then(() => {
  console.log('C');
}).then(() => {
  console.log('D');
});

Explanation:

  • All async functions always return a Promise, but the type of the underlying value changes the waiting behavior:
    1. When await receives a already resolved native Promise, no extra waiting is needed
    2. When await receives the return value of taskNoReturn, which is implicitly undefined (non-thenable), no extra waiting is needed
    3. When await receives the return value of taskPromiseReturn, which is a native Promise returned from an async function, the async return value rule adds 2 extra microtask waits, leading to the later output of A

Example 2

function getTask() {
  console.log(2);
  // Uncomment one option:
  // Option 1 (output: 1 2 4 5 3 6 7)
  // Promise.resolve()
  //   .then(() => console.log(5))
  //   .then(() => console.log(6))
  //   .then(() => console.log(7))

  // Option 2 (output: 1 2 4 5 6 7 3)
  return Promise.resolve()
    .then(() => console.log(5))
    .then(() => console.log(6))
    .then(() => console.log(7))
}

async function main() {
  console.log(1);
  await getTask();
  console.log(3);
}

main();
console.log(4);

Explanation: await will not resume execution of the current async function until the right-hand expression fully resolves. If the right-hand side is still pending (even if it has already added microtasks to the queue), await will block the rest of the async function until the Promise settles.

This behavior extends to cases where the Promise never settles:

function getPendingPromise() {
  return new Promise((resolve) => {
    console.log('B');
    // resolve() is never called
  })
}

async function main() {
  console.log(1);
  await getPendingPromise();
  console.log('3'); // This will never run
}

main();
console.log(4);
// Output: 1 B 4, 3 is never printed

Example 3 (thenable return from async function)

async function inner() {
  console.log(2);
  return {
    then(cb) {
      cb();
    }
  }
}

async function main() {
  console.log(1);
  await inner();
  console.log(3);
}

main();
console.log(4);

new Promise((resolve) => {
  console.log('B');
  resolve();
}).then(() => {
  console.log('C');
}).then(() => {
  console.log('D');
});

// Output: 1 2 4 B C 3 D

Core Rules Recap

async function return value rules

  • Non-thenable/non-Promise return: no additional microtasks, no waiting
  • Thenable return: wait 1 microtask tick
  • Native Promise return: wait 2 microtask ticks

await right-hand value rules

  • Non-thenable: immediately queue continuation microtask, no waiting
  • Thenable: wait 1 microtask tick before resuming
  • Resolved native Promise: immediately queue continuation microtask, no waiting (TC39 removed extra waits in modern implementations)

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.