Chapter 24

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:

  1. Each await in an async function corresponds to a yield in the generator
  2. The resolved/rejected value of the awaited Promise is fed back into the function body via gen.next(value) / gen.throw(error)
  3. Error propagation works by injecting via gen.throw(), which is why try/catch can catch await failures

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

  1. async/await is syntactic packaging of Generator + Promise: each await corresponds to a yield, the engine drives the generator forward, and the resolve/reject values of the awaited Promise are passed back via gen.next(value) and gen.throw(error).
  2. await expr always incurs at least one microtask delay, even when expr is an already-resolved Promise or a plain value. Code after await 42 always runs asynchronously — never synchronously.
  3. return Promise.reject(e) and throw e behave equivalently in an async function, but the former has one extra microtask delay (it must wait for the returned Promise to settle).
  4. Serial await (one after another) takes the sum of all wait times; use Promise.all for parallelism; when mixing concurrent Promises with sequential awaits, watch for unhandled rejection issues.
  5. Top-level await blocks parent modules that depend on it, but not sibling modules. ES2020 reduced the microtask delay of await from 2 to 1, affecting code that depends on precise microtask ordering.
Rate this chapter
4.6  / 5  (6 ratings)

💬 Comments