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.

A steel stepping mechanism on a black cast base: a vertical column of pawls driving three hinged arms over tracks of loose ball bearings, with one brass washer at the pivot.

Two async functions, written the same way, started in the same turn:

js
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 b3

b 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.

js
// 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.3
js
function 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.mjs

The 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
Return

The 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:

js
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 · promise3

async1 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.

Arrow keys to move, Enter to open.