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 returnA 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.
Creation time. The scope chain is lexical: fixed by where the function appears in the source, never by who calls it.
A live reference. Two functions created in the same scope share one environment record — a mutation by one is visible to the other.
// 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
.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 APIAn 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.
| protocol | shape | consumed 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 |
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.
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.
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.
03 The dependency ladder
seven rungs · one syntaxasync/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.
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.
04 The suspension rung
frames that leave the stackA 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.)
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.
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 listA 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.
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.
the combinators — all four, one table
| combinator | settles when | fulfills with | rejects when |
|---|---|---|---|
Promise.all | all fulfill, or any rejects | array of values, input order | first rejection (fail-fast) |
Promise.allSettled | all settle | array of {status, value|reason} | never |
Promise.race | first settles | first value | first rejection — whichever wins |
Promise.any | first fulfills, or all reject | first value | all rejected → AggregateError |
06 The fusion rung
generator + runner = awaitawait 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.
async function main() { const a = await getA(); const b = await getB(a); return a + b; } main().then(console.log);
function* main() { const a = yield getA(); const b = yield getB(a); return a + b; } spawn(main).then(console.log);
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 landsRejection 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.
try { const a = await getA(); // rejection rethrown HERE ↑ } catch (e) { recover(e); // so this catches it }
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 pathitems.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.
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 futuresAn 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.
| aspect | function* | async function* |
|---|---|---|
| calling it | runs no body · returns a generator object | runs no body · returns an async generator object |
next() returns | { value, done } — synchronously | Promise<{ value, done }> — always pending first |
what yield v does | emits v as-is, parks the frame | awaits v, then settles the oldest request, parks the frame |
await in the body | not allowed | allowed — suspends without settling any request |
| re-entrancy | next() while executing throws | queued as an AsyncGeneratorRequest — FIFO, never lost |
| consumed by | for…of · spread · destructuring | for await…of — which also calls return() on early exit |
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.
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*.
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 fullThe 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.
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.
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
sync · end of script nextTick · beats all microtasks microtask · .then microtask · queueMicrotask microtask · await resumption — then, GENUINELY nondeterministic — setImmediate ⇄ setTimeout 0 // split 6 : 4
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
nextTick beats promises; both beat every phase.
10 The parts bin
click any card · engine + spec detailEvery 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 correctionsCommon confusions, corrected
| the confusion | the 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
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.