open atlas
↑ Back to track
JavaScript Engine internals JSE · 07 · 04

async/await, desugared

An async function returns a promise; await suspends it, wraps the operand via PromiseResolve, and resumes via a microtask when the operand settles — a generator plus a driver. V8 7.2 (2018) removed await's extra ticks for native promises

JSE Middle ◷ 14 min
Level
FoundationsJuniorMiddleSenior

A teammate insists “await blocks until the data comes back — that is the whole point.” So why does the server keep handling 5,000 other requests while one of them awaits a slow database call? Because await does not block anything. It suspends one function and hands the thread back. Understanding the difference — suspend, not block — is the line between someone who uses async/await and someone who can debug it under load.

async/await is a generator plus a driver

async/await is pure syntax sugar; the engine desugars it to a coroutine. An async function is, mechanically, a generator whose yield points are the awaits, paired with an automatic driver that advances the generator each time an awaited value settles. Two facts fall out immediately:

  1. An async function always returns a promise. Calling it runs the body synchronously up to the first await, then returns a pending promise representing “the eventual completion of this function.” A return x inside fulfils that promise with x; a thrown error rejects it.
  2. await x is a suspension point. When control hits await x, the engine: wraps x via PromiseResolve (turning any value or thenable into a promise to subscribe to), registers a continuation as a reaction on that promise, and returns from the function — yielding the thread back to the host. When the awaited promise settles, the registered reaction (a microtask) resumes the function right after the await, with the value substituted in (or rethrowing the rejection).

Because resuming is a microtask, an async function with two awaits is not one synchronous unit — it is sliced into pieces that interleave with every other pending microtask between resumptions. This is the lens for the lesson: await is a yield to a driver, and the driver runs on the microtask queue.

The history: await used to cost three ticks

The original ES2017 spec for await was wasteful. To handle the case where you await a thenable, the spec said: take the operand, create a brand-new throwaway promise, resolve that with the operand, then .then onto it to subscribe. Even when the operand was already a native promise, you paid for the throwaway promise and its thenable-adoption job. The cost, measured in microtask ticks before the function resumed, was about three ticks for await p where p is a native promise — versus one tick for the equivalent p.then(...). In a hot loop full of awaits, this tripled the microtask traffic.

// Before V8 7.2, `await p` was roughly equivalent to:
const throwaway = new Promise(resolve => resolve(p)); // adopt p — extra ticks
throwaway.then(resume);                                // subscribe — another tick
// ≈ 3 microtask ticks to resume, even for a native promise p

V8 7.2 (2018): await ≈ p.then

V8 7.2 shipped the await optimization. When the awaited operand is a native promise (a genuine Promise, not a foreign thenable), the engine skips the throwaway-promise dance entirely and registers the resume reaction directly on the operand — exactly as p.then(resume) would. That collapses await p from ~3 ticks to 1 tick, matching hand-written .then. TC39 then changed the spec itself to match (the --harmony-await-optimization flag gated it during rollout; it is now the default and the spec behaviour). The practical upshot for seniors: since late-2018 engines, you no longer pay a tick tax for using await over .then on native promises — write whichever reads clearer. The tax only returns if you await a non-native thenable, which still needs the adoption job.

await tick costs across V8 versions
await nativePromise (pre-7.2)
~3 ticks
await nativePromise (7.2+, today)
1 tick
p.then(resume) — always
1 tick
await nonNativeThenable
+adoption tick
V8 version with the fix
7.2 (Oct 2018)
Flag during rollout
--harmony-await-optimization

Zero-cost async stack traces

The same release era brought zero-cost async stack traces. The problem: when an error surfaces three awaits deep, the synchronous call stack is long gone — each await returned to the host, so a naive trace shows only the innermost resumption with no caller chain. The old fix (kept under a flag) recorded a stack on every promise creation, which was expensive in hot paths. The zero-cost approach instead reconstructs the async frames on demand from the promise chain: V8 walks the [[PromiseFulfillReactions]] links (from lesson 3) backwards to rebuild the logical async call stack only when DevTools actually asks for it (when you hit a breakpoint or read error.stack in a debugging context). The fast path pays nothing — no per-await allocation — yet you still get a full async trace in the debugger. “Zero-cost” means zero cost when you are not debugging.

Error propagation: a rejection becomes a throw

When you wrap an await in try/catch and it actually catches the network error, you might wonder: how does an asynchronous rejection land in a synchronous catch block? Because the resume reaction is registered for both fulfilment and rejection, await converts an asynchronous rejection back into a synchronous-looking throw inside the function. When the awaited promise rejects, the engine resumes the function by throwing the rejection reason at the await expression — so a try/catch around the await catches it exactly as if a synchronous function had thrown. This is the entire ergonomic win of async/await: linear error handling over inherently non-linear control flow. The flip side is the trap from lesson 3 — an unawaited, uncaught rejected promise triggers unhandledrejection, because no reaction was registered to convert it into a catchable throw.

Quiz

A Node server awaits a slow DB query inside one request handler. What happens to the 5,000 other in-flight requests during that await?

Quiz

On a modern V8 (7.2+), how does the microtask tick cost of `await nativePromise` compare to `nativePromise.then(resume)`?

Order the steps

Order what the engine does when an async function hits `await fetchUser()` and the fetch later fulfils.

  1. 1 Run the function body synchronously up to the await expression
  2. 2 Wrap the operand via PromiseResolve and register a resume continuation on it
  3. 3 Return control to the host, which is free to run other tasks
  4. 4 When the awaited promise settles, enqueue the continuation as a microtask
  5. 5 The microtask runs: resume the function after the await with the value substituted in
Edge cases

A subtle consequence of “await = suspend via microtask”: await Promise.resolve() is a clean, idiomatic way to defer a single tick — it yields to the microtask queue and resumes after currently-pending microtasks. But it does not yield to the host (no rendering, no macrotasks run), because the resume is a microtask, not a macrotask. To actually yield to rendering you need a macrotask boundary (setTimeout, MessageChannel, scheduler.yield()) — the distinction from lesson 2 that decides whether the page can paint.

Recall before you leave
  1. 01
    Desugar async/await: what does an async function return, and what exactly does await do?
  2. 02
    Why did await once cost ~3 ticks, and what did V8 7.2 change?
  3. 03
    How do zero-cost async stack traces work, and what does 'zero-cost' mean?
Recap

async/await is syntax sugar the engine desugars into a coroutine: an async function is a generator whose awaits are yields, advanced by an automatic driver. Calling it runs synchronously to the first await and returns a pending promise (return fulfils it, throw rejects it). await x wraps x via PromiseResolve, registers a dual fulfil/reject continuation as a reaction, and returns control to the host — suspending the function, never blocking the thread, which is why one awaiting request does not stall thousands of others. On settle, the continuation runs as a microtask, resuming after the await with the value or rethrowing the rejection (so try/catch around await works, giving linear error handling over non-linear flow). Historically await cost about three microtask ticks because the spec adopted the operand through a throwaway promise; V8 7.2 (October 2018) special-cased native promises to register the resume directly, cutting await to one tick — equal to p.then — and TC39 adopted the change. The same era brought zero-cost async stack traces, which reconstruct async frames from the promise chain on demand so the fast path pays nothing. This closes the async-deep unit: from the engine having no loop, through the two queues, into the Promise state machine, and finally into the await coroutine built on top of it. Now when you see an await in a hot path, you can reason through it: one tick to resume on a native promise, the thread free between suspension and resumption, and a try/catch that will actually catch what the rejected promise threw — not folklore, mechanism.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done
Connected lessons
appears again in184

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.