Language internalsAsync machineryPart 2
What await Compiles To, and What It Costs
One async function compiled three ways — TypeScript's ES5 emit, V8's bytecode and the spec's Await steps — and the microtask turns each one predicts.

Two async functions, written the same way, started in the same turn:
const log = [];
async function a() {
log.push('a1'); await Promise.resolve();
log.push('a2'); await Promise.resolve();
log.push('a3');
}
async function b() {
log.push('b1'); await { then: (resolve) => resolve() };
log.push('b2'); await Promise.resolve();
log.push('b3');
}
a(); b();
setTimeout(() => console.log(log.join(' ')), 0);
// a1 b1 a2 a3 b2 b3b falls a full step behind, and the only difference between them is that one
awaited value has a then method instead of being a promise. That is not a
scheduling quirk. It is a count, and the count is knowable before you run the
program.
Counting turns instead of guessing
The microtask queue drains to empty between tasks, so “later” is not a useful unit. The useful unit is one turn of that queue. Ten lines are enough to measure it: start a self-perpetuating chain of microtasks, then see how many links of it have run when the continuation arrives.
// Node 24.20.0, V8 13.6.233.17-node.53
function measure(label, start) {
return new Promise((resolve) => {
let n = 0;
const tick = () => { n++; if (n < 40) Promise.resolve().then(tick); };
Promise.resolve().then(tick);
start(() => { console.log(String(n).padStart(2), label); resolve(); });
});
}
await measure('await Promise.resolve()', (done) => {
(async () => { await Promise.resolve(); done(); })();
});The same harness, run against six shapes of awaited value, prints this:
| What is awaited | Turns |
|---|---|
42 — a plain value |
1 |
Promise.resolve() |
1 |
{ then(resolve) { resolve(42) } } |
2 |
| an async function returning a value | 1 |
| an async function returning a promise | 3 |
new Promise((r) => r(Promise.resolve(42))) |
3 |
Measured, not inferred: those are the numbers the harness printed on Node 24.20.0. A plain value costs the same as a promise, which is the first surprise, and a thenable costs double, which is the one that reorders your logs.
Why one turn, and when it is two
The spec explains both numbers in two steps. Await ( value )
(ECMAScript 2026, §27.7.5.3) resolves the value with PromiseResolve(%Promise%, value), builds an
onFulfilled closure that resumes the suspended execution context, and calls
PerformPromiseThen. One reaction job, one turn.
PromiseResolve ( C, x ) (§27.2.4.7.1) is where the type of the awaited value
starts to matter: if x is already a promise whose constructor is C, it is
returned unchanged. Nothing is allocated and nothing is scheduled, so awaiting
a native promise reduces to a single then reaction. A plain value takes a fresh
promise resolved immediately, which also settles in one turn.
A thenable takes neither path. Resolving a promise with an object that has a
callable then queues NewPromiseResolveThenableJob
(§27.2.2.2), whose whole
body is a job that calls that then method later. The call itself is a microtask,
and only after it resolves does the reaction from Await get queued. Two turns,
by construction — and this is the cost V8 removed from the common case in
v7.2, when await went from three turns to one.
The switch your async function used to become
The spec says a suspended execution context is resumed. It does not show you the machine that gets suspended, and TypeScript will print one for you.
$ npx tsc src.ts --target es5 --outDir out5 # TypeScript 5.9.3function loadUser(id) {
return __awaiter(this, void 0, void 0, function () {
var res;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, fetchUser(id)];
case 1:
res = _a.sent();
return [2 /*return*/, res.name];
}
});
});
}That is the state machine, literally: a switch over a counter, one case per
await, with the function’s locals hoisted out of the body so they survive the
suspension. __awaiter supplies the promise and drives the machine, calling
.next() each time an awaited value settles.
The emit is now historical. TypeScript 7.0.2 answers tsc --target es5 with
error TS5108: Option 'target=ES5' has been removed, so reproducing the listing
above needs 5.9.3 or older — the Go port dropped more than
speed. At --target es2015 the
helper stays and the body becomes a real generator; at es2017 and above, the
async keyword survives untouched.
The same machine, one layer down
When the keyword survives, the machine does not disappear. It moves into the engine, and V8 will print that too:
$ node --print-bytecode --print-bytecode-filter=loadUser bc.mjsThe full listing is 83 bytes of bytecode. With the register shuffling elided, the instructions that carry the structure are these:
SwitchOnGeneratorState r0, [0], [1] { 0: @38 }
InvokeIntrinsic [_AsyncFunctionEnter], r2-r3
CallUndefinedReceiver1 r3, a0, [0]
InvokeIntrinsic [_AsyncFunctionAwait], r3-r4
SuspendGenerator r0, r0-r2, [0]
ResumeGenerator r0, r0-r2
InvokeIntrinsic [_GeneratorGetResumeMode], r0-r0
GetNamedProperty r3, [1], [2]
InvokeIntrinsic [_AsyncFunctionResolve], r3-r4
ReturnThe first instruction is the switch: SwitchOnGeneratorState jumps to the
resume offset held in the constant pool, exactly as case 1: does in the
TypeScript emit. SuspendGenerator stores the registers and returns;
ResumeGenerator loads them back. The counter that TypeScript keeps in
_a.label is a field V8 keeps on the generator object instead — and all of this
is the bytecode tier, before V8 decides to optimize
anything.
Two beliefs the count settles
The first is that return await promise is redundant. Measured from the
caller’s side, an async function ending in return await p settles the caller’s
await after 2 turns; the same function ending in return p takes 3.
Returning a promise from an async function resolves that function’s own promise
with a promise, which is the thenable path again, plus its extra job. The
await you were told to delete is the cheaper form.
The second is that the downleveled emit is slower because it is bigger. It is
not, at least not in turns. Compiling the same three-await function with
--target es5 and with --target es2017 and awaiting each from the outside
gives 4 turns for both. __awaiter reproduces the schedule faithfully; what
it costs is allocation and code size, not queue turns.
Where the count meets a then chain
The same arithmetic settles the interview question that used to have a different
answer. An async function awaiting another async function, racing a two-link
.then chain started immediately after it:
async function async1() { console.log('async1 start'); await async2(); console.log('async1 end'); }
async function async2() { console.log('async2'); }
console.log('script start');
async1();
new Promise((resolve) => { console.log('promise1'); resolve(); })
.then(() => console.log('promise2'))
.then(() => console.log('promise3'));
console.log('script end');
// script start · async1 start · async2 · promise1 · script end · async1 end · promise2 · promise3async1 end arrives before promise2, because await async2() costs one turn
and async2 returns a value rather than a promise. Under the three-turn await
that V8 shipped before v7.2, the same continuation would have been queued two
turns later, behind promise3 — I have not run an engine that old to confirm the
exact line order. The code did not change; the number of turns did.
Predicting the opening example
The count is now enough to derive the first listing without running it. a
awaits native promises, so a2 is queued for turn 1 and a3 for turn 2. b
awaits a thenable, so the job that calls its then runs on turn 1 and only then
queues b2 — behind a3, whose reaction was registered earlier in that same
turn. b3 lands on turn 3. That gives
a1 b1 a2 a3 b2 b3, which is what Node prints.
So the working model is: an await returns from the function and registers the
rest of it as a reaction. One turn if the value is a native promise or a plain
value, two if it has a then method, and one more for each promise wrapped
around a promise on the way.
What I have not tested is whether the two-turn thenable cost is observable across engines other than V8 — the spec requires the job, so it should be, but I have only run this on Node 24.20.0. If you care about an ordering in your own code, paste the harness above in and count the turns. It is a faster answer than reading the async function again.
FAQ
Frequently asked
Does await block anything?
No. The async function returns at the await, and the rest of it is registered as a continuation on a promise. The calling code keeps running to completion, and the continuation runs later, from the microtask queue.
Is return await really not redundant?
For timing, no. Measured on Node 24.20.0, a caller awaiting an async function that ends in return await resumes after two microtask turns; the same function ending in return of the same promise takes three. The two forms also differ inside try/catch, where only return await keeps the rejection catchable in the function.


