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

- Published: 2026-09-06
- Tags: javascript, typescript, v8
- Source: https://jsledger.com/blog/what-await-compiles-to/
- Language: en-US
- Author: Jonah Vail

---
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](/blog/the-event-loop-you-think-you-know/), 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 )`](https://tc39.es/ecma262/2026/#await)
(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`](https://tc39.es/ecma262/2026/#sec-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](https://v8.dev/blog/fast-async), 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](/blog/type-level-narrowing-in-typescript/). 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](/blog/how-v8-decides-to-optimize-your-function/).

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