await p→ suspend → microtask → resume →value
async javascript · closure → generator → promise → event loop · first principles
contents

Explorable explanation · the runtime, made mechanical

One await is a suspended stack frame, a queued microtask, a drained checkpoint — and only then a resumed function.

"JavaScript is single-threaded but non-blocking" is not the explanation — it is the sentence that still needs explaining. This document rebuilds await from parts you can hold — closures, iterators, generators, promises, queues — each with a live instrument running real code. Two short foundations first; then you ride a real script through the whole machine.

00 Foundations · part one

state that survives return

A closure is a function plus a live link to the scope it was born in

When a function is created, the engine stores a reference to the current environment record in the function's internal [[Environment]] slot. If the function outlives the call that created it, that environment record survives too — the variables inside it are not copied, not snapshotted, not garbage collected. They stay live, and every call to the function reads and writes the same records.

Popular teaching material calls this the function's "backpack" or COVE ("closed-over variable environment"). Those are excellent nicknames — but nicknames. The spec mechanism is the [[Environment]] chain, and the real name is worth having, because every construct in the rest of this document is a use of it. An iterator's position, a generator's locals, an async function's variables across an await — all of them live in environment records that stay reachable when no frame is on the stack.

defined at

Creation time. The scope chain is lexical: fixed by where the function appears in the source, never by who calls it.

shared, not copied

A live reference. Two functions created in the same scope share one environment record — a mutation by one is visible to the other.

⚗ live instrument · two real counters, two real environment records
both from the same makeCounter() — watch them not share state
heap · environment records (live)
console · real return values
// click a counter
// the instrument above runs exactly this:
function makeCounter(name) {
  let count = 0;                    // lives in an environment record…
  return () => ({ name, count: ++count }); // …reachable via [[Environment]]
}
const counterA = makeCounter('A');  // record №1  { count: 0 }
const counterB = makeCounter('B');  // record №2  { count: 0 } — independent
why this is rung one Async code is code that runs later. "Later" is only useful if the code still has its data. Closures are the mechanism that lets a callback, a .then handler, or a resumed await continuation see the variables that were in scope when it was written — after the frames that created them are long gone.

01 Foundations · part two

a closure with a standard API

An iterator is closure-held state behind one agreed method

The iterator protocol is minimal: an object with a next() method that returns an IteratorResult — { value, done }. The traversal position is private state closed over by next(). The iterable protocol is one level up: an object with a Symbol.iterator method that produces an iterator. for…of, spread, destructuring and Array.from are all consumers of that handshake.

The key is registered under a well-known symbol rather than a string so the protocol can be bolted onto any object — including ones that already have a next property for their own reasons — with zero chance of collision.

protocolshapeconsumed by
Iterator
{
  next() {
    return { value, done };
  }
}
optional return() / throw() — called by consumers on early exit
anything that pulls values one at a time
Iterable
{
  [Symbol.iterator]() {
    return iterator;
  }
}
for…of · [...x] · destructuring · Array.from · new Set(x)
AsyncIterator
{
  async next() {
    return { value, done };
  }
}
next() hands back a Promise of the result
manual pumping · runtimes
AsyncIterable
{
  [Symbol.asyncIterator]() {
    return asyncIterator;
  }
}
for await…of — streams, paginated APIs, sockets
⚗ live instrument · a real hand-rolled iterator over [4, 5, 6]
closure state · read live from the iterator
console · real IteratorResult objects
// call .next()
the design insight The protocol moves state management from the consumer to the producer. A for loop owns its own index; a for…of loop owns nothing — position, bounds, and even the source of values are the iterator's private business. That inversion is exactly what async code needs: the consumer says "next, whenever you're ready" and the producer decides what that means.

02 Explorable explanation · the life of one await

One script, four lanes, twelve frames

Strip the mystery away and one await is a short chain of concrete moves. An async function runs synchronously until its first await. The await wraps its operand via PromiseResolve, lifts the function's frame off the call stack, and registers the rest of the function as a PromiseReaction. When the promise settles, that continuation is enqueued on the microtask queue via HostEnqueuePromiseJob. And at the next microtask checkpoint the frame is restored and the function resumes — before any waiting task gets a turn. Press play and ride one script through all four lanes.

// the exact script the diagram below executes, frame by frame console.log('sync 1'); setTimeout(() => console.log('timer'), 0); (async () => { console.log('async start'); const v = await Promise.resolve(42); console.log('resumed with', v); })(); console.log('sync 2');
▶ four lanes · one script · 12 frames · click any station for the deep dive
00 / 11
FRAME 00 · — ·

Press play to run the script through the runtime

The glowing token is the locus of control. Cyan lane = your frames on the call stack. Amber = the microtask queue. Violet = the task queue and the host that feeds it.

call stack — your frames, running now microtask queue — promise jobs, drained to empty task queue — timers & I/O, one per loop turn a common misconception
the correction that unlocks everything await does not mean "the thread stops here." It means the engine popped one function's frame off the stack, parked its continuation as a microtask reaction, and let everything else keep running. Suspension is per-function, resumption is queued, and the ordering is a contract — not a race.
1
thread running your JS
2
queues with different priority
1
microtask tick per settled await
0
lines of your code that block the loop

03 The dependency ladder

seven rungs · one syntax

async/await is the top rung of a ladder, not a feature

Every rung is built from the one below it. A closure gives a function private, surviving state. An iterator is a closure with a standard API. A generator is an iterator whose frame the engine can park on the heap. A promise is a one-way latch with a reaction list. Bolt a promise-driven runner onto a generator and you have written await yourself. Ride the ladder bottom to top.

▶ the ladder · 8 stations · foundations → iteration → async
00 / 07
RUNG 00 · —

Press play to climb from closure to async/await

Cyan rungs are language foundations. Violet rungs are the iteration machinery. Amber rungs are the async machinery. Each panel shows what the rung contributes and what await inherits from it.

how to read the rest of this document Sections 00–01 were the first two rungs — you have already driven their instruments. Sections 04–09 climb the rest, one rung at a time, each with its own live instrument. Nothing below is simulated — every log line you'll see is produced by real closures, real generators, real promises and real queues executing in this page, timestamped by the engine itself.

04 The suspension rung

frames that leave the stack

A generator is a function whose frame the engine can park on the heap

Calling a generator function runs none of its body. It allocates a generator object — implementing both protocols above — whose internal [[GeneratorState]] starts as suspendedStart. Each next() pushes the function's frame back onto the stack and runs until the next yield; each yield pops the frame without destroying it — locals, position, even the half-evaluated expression survive in the object on the heap.

yield is a two-way valve: the operand goes out as { value, done: false }, and the argument of the resuming next(v) comes in as the value the paused yield expression evaluates to. (The very first next() starts the body, so its argument has no paused yield to land in — it is discarded.)

▶ the suspension machine · stack ⇄ heap · 8 frames
00 / 07
FRAME 00 · —

Press play to watch a stack frame move to the heap and back

Left column: the call stack. Right box: the generator object on the heap. The cyan block is the generator's frame — watch where it lives at each moment.

⚗ live instrument · a real generator · inject values through the yield valve
.next()
the generator's source
function* flow() {
  const num = 10;
  const injected = yield num;      // ← next(v) lands here
  yield 5 + injected;
  yield 6;
  return 'done for real';
}
console · real results from the real generator
// call .next() — try passing 2 on the second call
the bridge to async Read const injected = yield num again, slowly: pause here, hand a value out, and when someone resumes me, drop their value into my local. Replace "someone" with the promise machinery and "value" with a settled result, and that sentence becomes the definition of await. Section 06 performs the replacement.

05 The latch rung

a state machine with a reaction list

A promise is a one-way latch plus a list of things to run when it flips

Under the API, a promise is small. Three internal slots: a state (pending → exactly one of fulfilled / rejected, forever), a settled value, and a list of PromiseReaction records appended by every .then/.catch/.finally. Two rules do all the work. First: the executor you pass to new Promise(…) runs synchronously, immediately. Second: reactions never run synchronously, even if the promise is already settled. Settling doesn't call your handlers — it hands each reaction to HostEnqueuePromiseJob, which puts a PromiseReactionJob on the microtask queue.

That second rule is the sanity contract of async JavaScript: code after .then(fn) always runs before fn, whether the promise settles in a microsecond or a minute. No "sometimes sync, sometimes async" — the footgun that plagued callback libraries (Zalgo) is banned by the spec.

▶ the latch · state machine + reaction list · 7 frames
00 / 06
FRAME 00 · —

Press play to watch the latch flip exactly once

Left: the promise's three internal slots. Right: the microtask queue reactions are handed to. The danger frame shows what a second resolve() does — nothing.

⚗ live instrument · a real pending promise · you are the executor
attach handlers before and after settling — both work, both stay async
internal slots · live
console · note which lines are sync vs microtask
// attach a .then, then settle — or settle first, then attach

the combinators — all four, one table

combinatorsettles whenfulfills withrejects when
Promise.allall fulfill, or any rejectsarray of values, input orderfirst rejection (fail-fast)
Promise.allSettledall settlearray of {status, value|reason}never
Promise.racefirst settlesfirst valuefirst rejection — whichever wins
Promise.anyfirst fulfills, or all rejectfirst valueall rejected → AggregateError

06 The fusion rung

generator + runner = await

await is a yield whose runner is the promise machinery

Take the generator's pause valve from section 04 and the promise's reaction list from section 05, and you can write await yourself in a 10-line runner. Pull a value from the generator. Wrap it with PromiseResolve so plain values and thenables behave alike. Attach a .then that feeds the settled result back in through next(v). Repeat until done. That runner is the semantics of an async function — the engine implements it directly with its own machinery rather than literally rewriting your code into a generator, but every observable behaviour matches.

what you write
async function main() {
  const a = await getA();
  const b = await getB(a);
  return a + b;
}
main().then(console.log);
what it means
function* main() {
  const a = yield getA();
  const b = yield getB(a);
  return a + b;
}
spawn(main).then(console.log);
⚗ live instrument · spawn() below is real code, driving a real generator with real promises
the entire runner · this executes when you press run
function spawn(genFn) {
  const it = genFn();
  return new Promise((resolve, reject) => {
    function handle(r) {
      if (r.done) return resolve(r.value);
      Promise.resolve(r.value)            // PromiseResolve
        .then(
          v => advance(() => it.next(v)),   // inject the value
          e => advance(() => it.throw(e))   // rethrow at the yield
        );
    }
    function advance(resume) {
      try { handle(resume()); } catch (e) { reject(e); }
    }
    advance(() => it.next());
  });
}
console · every suspension and resumption, timestamped
// press run — getA and getB resolve on real timers
what the engine adds on top of your runner Semantics you'd have to hand-roll: synchronous start (an async function runs to its first await in the same tick it's called), rejection routing (a throw anywhere rejects the returned promise), and — since ES2019 — a fast path: await on a native promise costs one microtask tick, not the three the naïve desugar implies.

07 The second lane

where a throw actually lands

Rejection is not an exception — it is the other lane of the same machinery

Every structure in the last two sections had two lanes, and we have only driven one. A PromiseReaction records two handlers. The runner above passes two callbacks to .then. Follow the second one and the whole error story falls out. A throw anywhere inside an async function — the synchronous section included — never escapes at the call site; it rejects the promise the call already returned. An await whose operand rejects rethrows that rejection at the await expression, inside your frame, where an ordinary try/catch is standing. And a rejection still unobserved when the microtask queue drains becomes an unhandled rejection — an event, not a warning.

what you write
try {
  const a = await getA();
  // rejection rethrown HERE ↑
} catch (e) {
  recover(e);  // so this catches it
}
why it works · the runner from section 06
Promise.resolve(r.value).then(
  v => advance(() => it.next(v)),
  e => advance(() => it.throw(e))
);
// it.throw(e) resumes the parked frame
// by THROWING at the paused await —
// your catch sits on exactly that path
⚗ live instrument · break the pipeline anywhere, handle it three ways — real promises, real timers
1 · where does it fail?
2 · who handles it?

            
console · where the rejection actually surfaces
// pick a failure point and a handler, then run — this executes for real
the floating promise — the one leak try/catch cannot see items.forEach(async item => { … }) creates one promise per item and hands them to no one: forEach ignores return values, so nothing awaits them and no try/catch encloses their rejections. Every failure goes straight to unhandled. If the body awaits, reach for for…of with await (sequential) or await Promise.all(items.map(async …)) (parallel, fail-fast) — both put the promises back on a lane someone is watching.
a rejection is a value, not a stack unwind By the time a rejection surfaces, the stack that produced it is gone — the frame was parked, and the throw travelled as data inside a settled promise. That is why you construct the Error at the throw site (its stack is captured at construction), and why DevTools maintains async stack traces by stitching frames together across awaits: it is reconstructing a call chain that never existed on any one stack.

08 The stream rung

a pulled sequence of futures

An async generator is a generator whose next() returns a promise

await fused the suspension machinery with the promise machinery to wait for one future value. An async generator function — async function* — performs the same fusion in the other direction: it produces a whole sequence of future values, and hands them out one pull at a time. Calling it still runs no body code. It returns an async generator object implementing the async iteration protocols from section 01 — the same { value, done } shape, one promise later: every next() returns a Promise of an IteratorResult.

Two details carry all the semantics. First, yield v in an async generator awaits its operand before emitting — the consumer always receives the settled value, never a promise inside value. Second, the body cannot be re-entered, so calls made while it is running are not errors and not interleavings: each next() / return() / throw() becomes an AsyncGeneratorRequest in an internal queue, and the machine resumes the body once per request, in order. Call next() three times synchronously and you hold three pending promises that will settle strictly in sequence.

aspectfunction*async function*
calling itruns no body · returns a generator objectruns no body · returns an async generator object
next() returns{ value, done } — synchronouslyPromise<{ value, done }> — always pending first
what yield v doesemits v as-is, parks the frameawaits v, then settles the oldest request, parks the frame
await in the bodynot allowedallowed — suspends without settling any request
re-entrancynext() while executing throwsqueued as an AsyncGeneratorRequest — FIFO, never lost
consumed byfor…of · spread · destructuringfor await…of — which also calls return() on early exit
▶ the request queue · two pulls, one paused body · 8 frames
00 / 07
FRAME 00 · —

Press play to pull two values through the request queue

Left: the caller and the promises it holds. Middle: the async generator object — its state, its request queue, its parked frame. The second next() arrives while the body is mid-await, and nothing is lost.

⚗ live instrument · a real async generator · pull it, flood it, drain it
the stream's source
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function* stream() {
  let n = 1;
  while (n <= 4) {
    await sleep(300);  // producing takes time
    yield n++;         // settle the oldest request
  }
  return 'drained';
}
console · pending promises first, settlements in pull order
// press .next() ×3 — three pending lines print synchronously
why pull matters An event listener is push: the producer fires whenever it likes, and a slow consumer just falls behind. An async generator is pull: the body between two yields runs once per request, so a slow consumer automatically slows the producer. That is backpressure as a language feature — and it is exactly the contract Node streams expose through for await…of, and the reason paginated APIs map so cleanly onto async function*.
the bridge back to await await was one suspension subscribed to one latch. An async generator runs that same fusion once per pulled value: each next() enqueues a request, each yield passes through the promise machinery to settle the oldest one, and every resumption rides the microtask queue you already know. Nothing new was added to the runtime for this — the rungs just compose.

09 The scheduler

one turn, in full

The event loop runs one task, then drains every microtask

The event loop is a scheduler with an asymmetry at its heart. Tasks (timers, I/O, events, script execution) are taken one per turn, oldest first from a chosen task source. Then comes a microtask checkpoint: the microtask queue is drained to empty — including microtasks enqueued while draining. Only then may the browser render, and only then does the next task run. Promise reactions and await resumptions are microtasks; that is why they beat every waiting setTimeout, always. Node keeps the same contract on a different chassis — flip the ⇄ chassis toggle on the figure to watch libuv turn the identical asymmetry through its phases.

▶ one full turn of the loop · 9 frames · includes the starvation hazard
00 / 08
FRAME 00 · —

Press play to run one turn of the machine

The loop arrow shows where the scheduler is. Queues fill and drain as the turn progresses. Frame 5 is the hazard every interviewer asks about.

⚗ live instrument · the ordering race · real queues, real timestamps, this page, right now
the contestants · enqueued in this order
setTimeout(log('task · setTimeout 0'), 0);
Promise.resolve().then(log('microtask · .then'));
queueMicrotask(log('microtask · queueMicrotask'));
(async () => { await null;
  log('microtask · await resumption'); })();
requestAnimationFrame(log('rAF · before paint'));
log('sync · end of script');
actual arrival order · ms from race start (performance.now)
// press run — this executes for real; the numbers are the engine's own
the same race + node's two extra lanes
process.nextTick(log('nextTick · beats all microtasks'));
setImmediate(log('check · setImmediate'));
// …plus every contestant on the left.
// the live race adds these lanes automatically
// when it finds itself running under node
captured from node v22 · ten runs of this exact file
sync · end of script
nextTick · beats all microtasks
microtask · .then
microtask · queueMicrotask
microtask · await resumption
— then, GENUINELY nondeterministic —
setImmediate ⇄ setTimeout 0   // split 6 : 4
why the last two race — and when they don't At top level, setTimeout(fn, 0) is clamped to ≥1 ms; whether the timers phase sees it as expired depends on how fast the loop reaches it — sometimes yes, sometimes no. Schedule the same pair inside an I/O callback and the race disappears: the loop is standing in the poll phase, check comes next, so setImmediate wins every time. Ordering across phases is positional, not priority-based.

predict the output — then let the page run it for real

node.js — same contract, different chassis In Node the loop is libuv's phase machine — timers → pending → poll → check (setImmediate) → close — but the asymmetry survives intact: after every callback, Node drains the process.nextTick queue and then the promise microtask queue to empty before touching the next callback. nextTick beats promises; both beat every phase.

10 The parts bin

click any card · engine + spec detail

Every piece, and the one job it does

Eleven mechanisms carry the whole system. Each card opens a deep dive with the concrete V8 / host machinery on one side and the spec algorithm it implements on the other.

11 Debugging your mental model

fourteen corrections

Common confusions, corrected

the confusionthe correction
"The backpack is officially called the COVE."COVE / "backpack" are teaching nicknames. The spec mechanism is the function's [[Environment]] slot pointing at an environment record. Great mnemonic — not the term you'll find in ECMA-262 or a V8 heap snapshot.
"await blocks the thread."It suspends one function. The frame is popped, the continuation queued, and every other task, microtask and render proceeds normally.
"setTimeout(fn, 0) runs immediately after the current line."It runs as a task: after the current task finishes and after the entire microtask queue drains. Browsers also clamp nested timers (≥5 deep) to a 4 ms minimum.
"Promises run my code in parallel."Promises schedule continuations; your JS still runs on one thread. The parallelism, when it exists, is in the host — network, timers, threadpool I/O — never in two of your functions at once.
"The executor in new Promise(fn) is asynchronous."The executor runs synchronously, before new Promise even returns. Only reactions are deferred.
".then on an already-settled promise fires synchronously."Never. The reaction is enqueued as a microtask immediately, but it still waits for the stack to empty. Sync-or-async ambiguity ("Zalgo") is banned by design.
"Async functions are literally compiled to generators."They share the suspension machinery conceptually, and the desugar is behaviourally faithful — but engines implement async functions with dedicated spec machinery (and the ES2019 fast path makes await cheaper than a hand-rolled runner).
"Microtasks can't hurt the page."They can starve it. The checkpoint drains to empty, so a microtask that enqueues another microtask forever blocks rendering and every task — a synchronous while(true) in disguise.
"for await…of requires Symbol.asyncIterator."It prefers it, but falls back to Symbol.iterator, wrapping each result in a promise. You can for await over an array of promises.
"yield in an async generator hands out whatever I yielded — even a promise."It awaits its operand first (spec: AsyncGeneratorYield performs Await). The consumer's value is always the settled result; a rejected operand rejects that request's promise instead.
"for await…of is push-based, like addEventListener."Pure pull. The body between two yields runs once per request — a slow consumer automatically slows the producer. That is backpressure; and break calls return() to close the stream.
"An unhandled rejection is just a warning."It's an event with teeth: unhandledrejection in the browser; in Node, unhandledRejection — which by default crashes the process.
"try/catch can't catch async errors."Around an await it is exact: the machinery resumes the parked frame by throwing at the await expression (the runner's it.throw(e)) — which is inside your try. What it cannot see is a promise nobody awaits: the floating promise.
"A throw before the first await escapes at the call site."Never. An async function converts every throw — synchronous section included — into a rejection of the promise it already returned. Callers always receive a promise; failure arrives on its reject lane, one microtask later at the earliest.

12 Compression

The shortest correct version

A closure lets state outlive the call that made it. An iterator standardizes that state behind next(). A generator lets the engine park an entire frame as that state, pausing at yield and resuming with an injected value. A promise is a one-way latch that queues its reactions as microtasks. await fuses the last two: pause the frame, subscribe the resumption to the latch, let the microtask checkpoint — which always outranks tasks — restore the frame with the settled value. One thread, two queues, zero magic. And an async generator is that whole stack run once per pulled value — a queued request in, an awaited yield out. Errors ride the second lane of the same records: a throw — synchronous section included — rejects the function's promise, an await rethrows whatever it observes, and nothing else in the machine changes.

When async behaviour surprises you, ask one question: which queue did this land in, and why? The answer is always determined by the API — promise machinery lands in microtasks, host callbacks land in tasks — and the checkpoint rule does the rest.

Back to JIT-Atlas