Hidden Implementation Details of JavaScript async/await You May Not Know
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:
- Synchronous code runs first, outputs
5 setTimeoutis added to the macrotask queue- Execute
taskA, outputs1 - Execute
taskB, outputs3 - Promise executor runs synchronously, outputs
4. SincetaskBreturns a native Promise, we need to wait 2 microtask ticks before resuming execution oftaskA, soAAAis not output yet - Exit
taskA, continue synchronous execution, output7 - Finish synchronous code, output
11 - Process first microtask: output
8 - Process second microtask: output
9 - Two microtask ticks have passed, resume
taskAand outputAAA - Process the last queued microtask: output
10 - Process the macrotask from
setTimeout: output6
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:
- When
awaitreceives a already resolved native Promise, no extra waiting is needed - When
awaitreceives the return value oftaskNoReturn, which is implicitlyundefined(non-thenable), no extra waiting is needed - When
awaitreceives the return value oftaskPromiseReturn, 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 ofA
- When
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)