async/await: The Complete Desugaring to Generator Form
async/await is not magic. It is syntactic packaging around Generator + Promise. Understanding this equivalence makes every "strange behavior" in execution ordering, error propagation, and microtask scheduling immediately clear.
🔹 Level 1 · What You Need to Know
Basic Behavior of async Functions
An async function always returns a Promise, regardless of what it contains:
async function getValue() {
return 42 // equivalent to: return Promise.resolve(42)
}
async function getError() {
throw new Error('fail') // equivalent to: return Promise.reject(new Error('fail'))
}
async function getPromise() {
return Promise.resolve(99) // still a Promise — not a nested Promise
}
getValue().then(v => console.log(v)) // 42
getError().catch(e => console.log(e.message)) // 'fail'
getPromise().then(v => console.log(v)) // 99
The Waiting Semantics of await
await expr pauses the async function, waits for the Promise corresponding to expr to settle, then resumes:
async function fetchUser(id) {
// pause until fetch completes
const response = await fetch(`/api/users/${id}`)
// pause until JSON parsing completes
const user = await response.json()
return user
}
// Error handling
async function safeGet(url) {
try {
const res = await fetch(url)
return await res.json()
} catch (err) {
console.error('Request failed:', err.message)
return null
}
}
Serial vs Parallel
await is serial — one after another. Promise.all achieves parallelism:
// Serial: total time ≈ time(A) + time(B)
async function serial() {
const a = await fetchA() // wait for A to complete
const b = await fetchB() // then wait for B
return [a, b]
}
// Parallel: total time ≈ max(time(A), time(B))
async function parallel() {
const [a, b] = await Promise.all([fetchA(), fetchB()]) // wait for both simultaneously
return [a, b]
}
// Common mistake: looks parallel, is actually serial
async function wrongParallel() {
const promiseA = fetchA() // start request (non-blocking)
const promiseB = fetchB() // start request (non-blocking)
const a = await promiseA // wait for A
const b = await promiseB // wait for B
return [a, b]
// This is approximately parallel since both requests are started
// BUT if A throws, B's error may become an unhandled rejection
// The correct approach is still Promise.all
}
Top-Level await (ESM Modules)
In ES modules, await can be used at the top level without wrapping in an async function:
// config.mjs
const config = await fetch('/api/config').then(r => r.json())
export { config }
// Modules that import config.mjs wait for it to finish
// import { config } from './config.mjs' — automatically waits for top-level await
🔸 Level 2 · How It Actually Works
The Complete Desugaring of async/await
The most direct way to understand async/await is to write the equivalent Generator + Promise code:
// === Original async/await code ===
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`)
const user = await response.json()
return user
}
// === Equivalent Generator + Promise implementation (conceptually) ===
function fetchUser(id) {
// async function returns a Promise
return new Promise((resolve, reject) => {
// the async function body corresponds to a generator
const gen = (function* () {
try {
const response = yield fetch(`/api/users/${id}`)
// yield ≡ await: pause and hand the Promise to the outside
const user = yield response.json()
// return ≡ resolve
resolve(user)
} catch (e) {
// uncaught errors ≡ reject
reject(e)
}
})()
// The function that drives the generator (the async runtime)
function step(value) {
let result
try {
result = gen.next(value) // feed the previous await's result back in
} catch (e) {
reject(e)
return
}
if (result.done) return // generator finished; Promise already resolved
// result.value is the Promise that was yielded
Promise.resolve(result.value).then(
v => step(v), // Promise resolve → continue execution
e => {
try {
result = gen.throw(e) // Promise reject → throw at the yield site
} catch (e2) {
reject(e2)
}
}
)
}
step(undefined) // first call, no initial value
})
}
This equivalence reveals the following key points:
- Each
awaitin an async function corresponds to ayieldin the generator - The resolved/rejected value of the awaited Promise is fed back into the function body via
gen.next(value)/gen.throw(error) - Error propagation works by injecting via
gen.throw(), which is whytry/catchcan catchawaitfailures
The Microtask Scheduling Model of await
What the engine does when it encounters await expr:
Execution of await expr:
async function body reaches await expr
│
▼
1. Evaluate expr → V
│
▼
2. If V is not a Promise: V = Promise.resolve(V)
(even a non-Promise value incurs one microtask delay)
│
▼
3. Register "resume async function when V resolves" as a then-callback on V
(via PerformPromiseThen(V, resumeCallback, rejectCallback))
│
▼
4. Async function suspends; control returns to caller
│
▼
5. (current synchronous code continues...)
│
▼
6. When V resolves:
schedule resumeCallback(resolvedValue) in the microtask queue
│
▼
7. Microtask executes: async function resumes from await,
await expr evaluates to resolvedValue
console.log('1: sync start')
async function demo() {
console.log('3: async function start')
const v = await Promise.resolve('resolved')
console.log('5: after await, v =', v)
}
demo()
console.log('4: sync code after demo()')
// Output order:
// 1: sync start
// 3: async function start
// 4: sync code after demo()
// 5: after await, v = resolved
When an async Function's Promise Resolves
When async function f()'s Promise settles:
async function f() {
// ... function body
}
Case 1: function body reaches return value
→ f()'s Promise resolves with value
Case 2: function body throws error
→ f()'s Promise rejects with error
Case 3: function body reaches end (no return)
→ f()'s Promise resolves with undefined
Case 4: function body returns aPromise (another Promise)
→ f()'s Promise follows aPromise (thenables are flattened)
→ NOT a nested Promise<Promise<T>>, but waits for aPromise to settle
Note: Case 4 means
async function f() { return Promise.reject(1) }
is equivalent to
async function f() { throw 1 }
Both cause f()'s Promise to reject with 1
The Historical "Double Microtask" Problem of await
The spec previously required await to produce two microtask schedules (when awaiting a native Promise). ES2020 optimized this to one:
Old spec (pre-ES2020 behavior):
await nativePromise
1. nativePromise.then(v => resolve(tempPromise, v)) ← 1st microtask
2. tempPromise.then(resumeCallback) ← 2nd microtask
Total: 2 microtask delays
New spec (ES2020 optimization):
await nativePromise
1. nativePromise.then(resumeCallback) ← registered directly
Total: 1 microtask delay
Impact (the following code produces different output order in old vs new spec):
async function f() {
await Promise.resolve() // old spec needed 2 microtasks
console.log('A')
}
f()
Promise.resolve().then(() => console.log('B'))
// Old spec: B A (B on 1st microtask, A on 2nd)
// New spec: A B (await takes only 1 microtask)
Module Execution Order with Top-Level await
Top-level await suspends the current module's execution, but does not block sibling modules (other dependencies of the same parent module):
Module dependency graph (top-level await scenario):
main.mjs
├── a.mjs (has top-level await, needs 500ms)
└── b.mjs (no top-level await, synchronous)
Loading sequence:
1. Load a.mjs → start executing → hit top-level await → suspend
2. Load b.mjs → synchronously completes
3. a.mjs's await completes → a.mjs continues and finishes
4. main.mjs continues executing
Rules:
- Top-level await blocks modules that depend on it (main.mjs waits for a.mjs)
- Top-level await does NOT block sibling modules (b.mjs executes normally)
- If both a.mjs and b.mjs have top-level await, their awaits run concurrently
🔺 Level 3 · What the Spec Says
27.7 AsyncFunction Objects
Spec section 27.7 defines AsyncFunction objects. When calling async function f() {}, f is an ordinary ECMAScript function object. The difference is that when its [[ECMAScriptCode]] is evaluated, the engine uses AsyncFunctionStart to initiate asynchronous execution.
The AsyncFunctionStart Algorithm
When an async function is called (F.[[Call]]), before executing the function body, the spec runs AsyncFunctionStart(promiseCapability, asyncFunctionBody):
AsyncFunctionStart(promiseCapability, asyncFunctionBody):
1. runningContext = the running execution context
2. asyncContext = a copy of runningContext (a new execution context)
3. Set asyncContext's code evaluation state:
When resumed, execute these steps:
a. result = result of evaluating asyncFunctionBody
b. Assert: current execution context is asyncContext (was resumed)
c. Remove asyncContext from execution context stack
d. If result.[[Type]] is normal:
Call promiseCapability.[[Resolve]](result.[[Value]])
Else (return / throw):
If result.[[Type]] is return:
Call promiseCapability.[[Resolve]](result.[[Value]])
Else:
Call promiseCapability.[[Reject]](result.[[Value]])
4. Push asyncContext onto the execution context stack
5. Resume asyncContext (start executing the function body)
6. Assert: asyncContext has been removed from execution context stack
(when the first await is encountered, asyncContext is suspended and popped)
7. Return promiseCapability.[[Promise]]
The Await Abstract Operation
await expr in the spec corresponds to the Await(value) operation (spec 27.7.5.3):
Await(value):
1. asyncContext = the running execution context
// save the current async function's execution context
2. promise = PromiseResolve(%Promise%, value)
// wrap value as a Promise (if already a native Promise, use it directly)
3. stepsFulfilled = algorithm steps for Await Fulfilled Functions
4. onFulfilled = CreateBuiltinFunction(stepsFulfilled)
// onFulfilled is called when promise resolves:
// resumes asyncContext, using resolvedValue as the value of Await
5. stepsRejected = algorithm steps for Await Rejected Functions
6. onRejected = CreateBuiltinFunction(stepsRejected)
// onRejected is called when promise rejects:
// resumes asyncContext, throwing rejectedValue at the await site
7. PerformPromiseThen(promise, onFulfilled, onRejected)
// register callbacks
8. Remove asyncContext from execution context stack
// suspend the current async function
9. Set asyncContext.[[Suspended]] to true
10. Return (hand control back to the context that called Await)
The onFulfilled (Await Fulfilled Function) algorithm:
Await Fulfilled Function(value):
// called when the awaited promise resolves
1. asyncContext = the asyncContext associated with this function
2. prevContext = the running execution context
3. Push asyncContext onto the execution context stack (resume the async function)
4. In asyncContext:
Return value as a NormalCompletion of the Await operation
// i.e.: the await expression's value is now 'value'
5. Remove asyncContext from execution context stack
Resume prevContext
PerformPromiseThen in await
Spec 27.2.5.4.1 PerformPromiseThen(promise, onFulfilled, onRejected, resultCapability):
PerformPromiseThen(promise, onFulfilled, onRejected):
1. If promise.[[PromiseState]] is 'pending':
- Append { [[Capability]]: resultCapability, [[Type]]: 'Fulfill',
[[Handler]]: onFulfilled }
to promise.[[PromiseFulfillReactions]]
- Append { [[Capability]]: resultCapability, [[Type]]: 'Reject',
[[Handler]]: onRejected }
to promise.[[PromiseRejectReactions]]
2. If promise.[[PromiseState]] is 'fulfilled':
- value = promise.[[PromiseResult]]
- Enqueue the fulfill reaction as a microtask (EnqueueJob)
// Note: even if the promise is already resolved, execution is async (microtask)
3. If promise.[[PromiseState]] is 'rejected':
- Similar to step 2, schedule reject reaction
This explains why `await 42` still has a microtask delay:
PromiseResolve(42) → creates an already-fulfilled Promise
PerformPromiseThen adds onFulfilled to the microtask queue
Only after current synchronous code completes does the microtask execute
→ async function resumes from the microtask queue
💎 Level 4 · Edge Cases and Traps
Trap 1: await of a Non-Promise Still Has One Microtask Delay
await 42 is not immediate. It is equivalent to await Promise.resolve(42) and always incurs one microtask delay:
async function f() {
console.log('A')
await 42 // even a synchronous value produces a microtask delay
console.log('C')
}
f()
console.log('B')
// Output: A → B → C
// 'B' appears before 'C' because await 42 creates a microtask delay
This is spec-guaranteed behavior: PromiseResolve(42) creates an already-fulfilled Promise, then PerformPromiseThen enqueues the resume callback in the microtask queue rather than executing it synchronously.
Trap 2: return Promise.reject(1) Is Equivalent to throw 1
async function f1() { return Promise.reject(1) }
async function f2() { throw 1 }
// Both return Promises that reject with 1
f1().catch(e => console.log('f1:', e)) // f1: 1
f2().catch(e => console.log('f2:', e)) // f2: 1
// Reason: when an async function encounters return somePromise,
// it doesn't resolve(somePromise) directly — it "follows" somePromise.
// If somePromise rejects, the outer Promise rejects too.
// Subtle difference: returning a rejected Promise has one extra microtask delay
// compared to throwing (because it must wait for somePromise to settle)
Trap 3: Silent Errors in Concurrent Requests
When using await a; await b for concurrent requests, if b rejects first, its error may become an unhandled rejection:
// Risky pattern: if pb rejects before we await it, it may be unhandled
async function dangerous() {
const pa = slowRequest() // assume takes 1000ms
const pb = fastFail() // assume rejects after 100ms
const a = await pa // wait for pa (1000ms)
// at this point, pb has been rejected for 900ms but we haven't awaited it yet
// in some environments, pb's rejection may be flagged as unhandled
const b = await pb // we catch pb's error here
return [a, b]
}
// Safe pattern: Promise.all ensures both rejections are handled
async function safe() {
const [a, b] = await Promise.all([slowRequest(), fastFail()])
return [a, b]
}
// If you need to handle errors independently:
async function safeIndependent() {
const [a, b] = await Promise.allSettled([slowRequest(), fastFail()])
if (a.status === 'rejected') handleErrorA(a.reason)
if (b.status === 'rejected') handleErrorB(b.reason)
}
Trap 4: Top-Level await Affects Module Initialization Order
Top-level await causes all parent modules that depend on it to wait, potentially creating unexpected initialization order issues:
// database.mjs — has top-level await
export const db = await connectToDatabase() // assume takes 200ms
// logger.mjs — no top-level await, synchronous
export const logger = new Logger()
// app.mjs — depends on both
import { db } from './database.mjs'
import { logger } from './logger.mjs'
// Execution order:
// 1. database.mjs starts executing → await connectToDatabase() → suspends
// 2. logger.mjs starts executing → completes synchronously
// 3. database.mjs's await completes → continues and finishes
// 4. app.mjs starts executing (after both dependencies are ready)
// Important: if database.mjs's top-level await throws,
// app.mjs fails to load too (but logger.mjs is unaffected — it already ran)
Trap 5: for await...of vs for...of with Async Iterators
for await...of uses Symbol.asyncIterator, not Symbol.iterator:
async function* asyncRange(start, end) {
for (let i = start; i < end; i++) {
await delay(100)
yield i
}
}
// Wrong: for...of does not await each yielded Promise
// (it won't throw, but you get Promise objects instead of values)
// for (const v of asyncRange(1, 5)) {
// console.log(v) // prints Promise objects, not numbers
// }
// Correct: for await...of
async function main() {
for await (const v of asyncRange(1, 5)) {
console.log(v) // 1, 2, 3, 4 (each waiting 100ms)
}
}
// for await...of internally is equivalent to:
// const iter = asyncRange(1, 5)[Symbol.asyncIterator]()
// while (true) {
// const { value, done } = await iter.next()
// if (done) break
// console.log(value)
// }
Chapter Summary
- async/await is syntactic packaging of Generator + Promise: each
awaitcorresponds to ayield, the engine drives the generator forward, and the resolve/reject values of the awaited Promise are passed back viagen.next(value)andgen.throw(error). await expralways incurs at least one microtask delay, even whenexpris an already-resolved Promise or a plain value. Code afterawait 42always runs asynchronously — never synchronously.return Promise.reject(e)andthrow ebehave equivalently in an async function, but the former has one extra microtask delay (it must wait for the returned Promise to settle).- Serial
await(one after another) takes the sum of all wait times; usePromise.allfor parallelism; when mixing concurrent Promises with sequential awaits, watch for unhandled rejection issues. - Top-level
awaitblocks parent modules that depend on it, but not sibling modules. ES2020 reduced the microtask delay ofawaitfrom 2 to 1, affecting code that depends on precise microtask ordering.