Suspending frames: generators & async functions
Learn how generators and async functions save suspended frames, resume with next or await, and compile down to explicit state machines.
- 01Explain suspended framesDescribe what has to survive when a generator yields or an async function awaits instead of returning normally.
- 02Drive a generator safelyPredict how
next(),return(), andthrow()move a generator through suspended-start, suspended-yield, executing, and completed states. - 03Connect syntax to state machinesRecognize the heap object, resume offset, microtask continuation, and switch-label shape that engines and transpilers use.
Pause a function without losing it
A normal function call is simple: enter the function, create a stack frame, run the body, then return and remove the frame. A generator or async function has one extra trick. It can pause mid-body, give control back to the caller, and later continue from the exact point where it paused.
A suspended frame is the saved execution state for a paused function: the live local bindings, parameters, receiver or context, and the resume point needed to continue later.
This lesson is about the engine shape behind the syntax. For the language surface, link back to generators, generators for control flow, async/await, iteration protocols, microtasks, and transpilers and polyfills. For ordinary stack behavior, revisit the call stack and stack and heap.
Pause a video halfway through. Later, press play and it continues at the same moment without starting again. A suspended frame keeps the same kind of resume point and saved state.
- In real life: The pause marker keeps the next moment
- In JavaScript: The resume point says where execution continues
- In real life: The video keeps its current settings
- In JavaScript: Live locals and parameters keep their values
- In real life: The video waits in your list
- In JavaScript: The generator or async object survives on the heap
- In real life: Pressing play continues from there
- In JavaScript:
next()or a promise reaction restores the frame
Where the analogy stops: A video plays pictures and sound. JavaScript suspension saves state; it does not start a parallel task on the same thread.
Normal frames vs suspended frames
A stack frame is the engine's record for one active call. It holds the instruction pointer, local slots, parameters, and return information while that call is running. In a normal call, the frame is short-lived. Once the function returns, the frame is gone.
A generator or async function cannot throw away everything at the pause point. If line 4 yields a ticket number, line 5 still needs the same token value when you call next() later. Engines solve that by keeping the resumable state with an object or continuation, then rebuilding the active frame when it runs again.
| Case | While running | After it gives control back |
|---|---|---|
| Normal function call | Lives on the call stack while it runs. | Destroyed when the function returns or throws. |
Generator suspended at yield | Live locals, parameters, receiver, and resume point are retained by the generator object. | Restored when next(), return(), or throw() resumes it. |
Async function suspended at await | The continuation and live bindings survive while the awaited promise is pending. | Resumed by a promise reaction job in the microtask queue. |
| Transpiled state machine | A label or state field plus saved locals stand in for the hidden suspended frame. | A switch statement jumps to the next resume point. |
This is JavaScript semantics first: the program must resume correctly. The exact storage layout is an engine strategy. V8's strategy is visible enough to test, so this lesson proves stable V8 bytecode names and generator-object fields without asserting memory addresses.
Saving a generator's frame on the heap
A generator function is written with function*. Calling it does not run the body. Instead, it returns a generator object. A yield expression returns a value to the caller and pauses the body. The local values that will be needed later must survive outside the active call stack.
Step through a generator frame as yield saves locals and the resume point, then next() restores them.
script
function* ticketCounter(start) { let token = start; while (token < start + 3) { const command = yield token; token += command === "skip" ? 2 : 1; } return "closed at " + token;} tickets.next();tickets.next("skip");tickets.next();Step through the replay. Line 10 creates the generator object in suspended-start. Line 11 enters the body. Line 2 stores token. Line 4 yields 1440 and suspends. When line 12 calls next("skip"), the old frame is restored: the yield expression finishes with "skip", line 5 updates the same token, and line 4 yields again.
Put an unfinished lunch box aside and close the lid. The food is not on the table, but it is not lost. A generator object has the same storage role while its function is paused.
- In real life: A label identifies your lunch box
- In JavaScript: The generator object identifies the paused computation
- In real life: The box keeps the food inside
- In JavaScript: Live bindings are saved between resumes
- In real life: The lid marks that it is not finished
- In JavaScript: The continuation points to the next bytecode
- In real life: Opening it lets you continue eating
- In JavaScript:
next()restores the saved frame and continues
Where the analogy stops: A lunch box does not do work by itself. A JavaScript generator runs only when you explicitly resume it.
V8 evidence you can reproduce in Node
In V8 12.4, printing bytecode for a generator shows the control-flow machinery. The lesson test runs the probe in a child Node process and asserts only the stable names SwitchOnGeneratorState, SuspendGenerator, and ResumeGenerator.
function* ticketCounter(start) { let token = start; yield token; token += 1; return token;} const gen = ticketCounter(41);gen.next();gen.next();$ node --print-bytecode --print-bytecode-filter=ticketCounter generator-probe.mjs[generated bytecode for function: ticketCounter]SwitchOnGeneratorState ...SuspendGenerator ...ResumeGenerator ...The object itself is visible with V8 native syntax. The lesson test runs the following code with --allow-natives-syntax and asserts stable substrings such as JSGeneratorObject, continuation, and register file.
function* ticketCounter(start) { let token = start; yield token; token += 1; return token;} const gen = ticketCounter(41);%DebugPrint(gen);gen.next();%DebugPrint(gen);%DebugPrint excerptTextDebugPrint: ... [JSGeneratorObject] - resume mode: .next() - continuation: 0 (suspended) - register file: ...DebugPrint: ... [JSGeneratorObject] - continuation: 1 (suspended)The tests check Node starts with v22. and V8 starts with 12.4.. They never assert addresses, full bytecode offsets, or exact register counts. Those details can differ between macOS arm64 and Linux x64.
Resuming with next(), return(), and throw()
A generator object is the handle you use to resume the saved frame. next(value) continues normally. After the first start, that value becomes the result of the paused yield expression. return(value) closes the generator after cleanup. throw(error) resumes by throwing at the paused yield, so a try/catch inside the generator can handle it.
function* ticketCounter(start = 1440) { let token = start; try { while (token < start + 3) { const command = yield token; token += command === "skip" ? 2 : 1; } return "closed at " + token; } catch (error) { return "handled " + error.message; }} const counter = ticketCounter();counter.next();counter.next("skip");suspended-startnone yetwaitingNo calls yet. The generator body has not run.
Start from suspended-start. Press next() to enter the generator, then try sending skip, closing it with return(), or throwing into the paused yield.
Use the playground as a live object, not a fake console. The result values come from real method calls. The state label is a teaching model because browsers do not expose [[GeneratorState]] directly.
throw() enters catch; return() closesfunction* station() { try { yield "boarding"; yield "moving"; } catch (error) { yield "caught " + error.message; } finally { console.log("cleanup"); } return "done";} let trip = station();console.log(JSON.stringify(trip.next()));console.log(JSON.stringify(trip.throw(new Error("delay"))));console.log(JSON.stringify(trip.next()));console.log(JSON.stringify(trip.next())); trip = station();console.log(JSON.stringify(trip.next()));console.log(JSON.stringify(trip.return("closed")));console.log(JSON.stringify(trip.next()));Lines 14 through 17 throw into the paused yield. The generator catches the error, yields a recovery value, then runs finally. Lines 20 and 21 show return("closed"): it resumes only far enough to clean up and finish.
next() has no receiver yieldThe argument to the very first next(value) is ignored by the body. There is no suspended yield waiting to receive it yet. Send meaningful input on the second or later resume call.
How await suspends and resumes
An async function always returns a promise. It starts synchronously, just like a normal function, until it reaches an await. If the awaited promise is not ready, the async frame suspends. When the promise settles, the continuation runs as a promise reaction job in the microtask queue.
Follow an async function that starts synchronously, suspends at await, and resumes from the microtask queue after its promise settles.
script
async function resumeAfterAwait(gate) { console.log("async start"); const token = await gate; console.log("async resumed " + token); return token + 1;} let release;console.log("script start");const resultPromise = resumeAfterAwait(gate);resultPromise.then((value) => console.log("then " + value));queueMicrotask(() => { console.log("settle promise"); release(41); });queueMicrotask(() => console.log("already queued microtask"));console.log("script end");The ordering is the key. The script logs script end before the awaited value is available. A queued microtask settles the promise. Another microtask that was already queued runs next. Only then does the async frame resume and log async resumed 41.
async function fetchTicket(promise) { const value = await promise; return value + 1;} fetchTicket(Promise.resolve(41));$ node --print-bytecode --print-bytecode-filter=fetchTicket async-probe.mjs[generated bytecode for function: fetchTicket]SwitchOnGeneratorState ..._AsyncFunctionAwaitUncaught ...SuspendGenerator ...ResumeGenerator ...V8 lowers async functions through generator-like suspend and resume machinery plus async-function intrinsics. That is implementation detail. The portable rule is simpler: await pauses this async function, lets the current turn finish, then resumes from a microtask after the awaited promise settles.
Generator objects and their state
The ECMAScript spec describes a generator object with an internal [[GeneratorState]]. The useful states are suspended-start, executing, suspended-yield, and completed. You cannot read that internal slot directly, but behavior exposes it.
let self;function* reentrantProbe() { try { self.next(); } catch (error) { console.log(error.name + ": " + error.message); } yield "first pause"; return "finished";} self = reentrantProbe();const first = self.next();console.log(first.value, first.done);const second = self.next();console.log(String(second.value), second.done);const third = self.next();console.log(String(third.value), third.done);Line 4 tries to call next() while the same generator is already running. V8 throws TypeError: Generator is already running. After line 16 returns finished, line 18 proves the completed rule: another next() returns undefined with done: true.
| State | Meaning | Resume behavior |
|---|---|---|
suspended-start | The generator object exists, but the body has not executed yet. | The first next() starts the body; an argument sent to this first call is ignored by the body. |
executing | The body is currently running. | A re-entrant next()/return()/throw() throws TypeError: Generator is already running. |
suspended-yield | The body paused at a yield and saved its live bindings plus the continuation. | A later resume call makes the yield expression finish. |
completed | The body returned, ran off the end, or closed through return()/an uncaught throw. | Further next() calls return { value: undefined, done: true }. |
const gen = ticketCounter(1440);just returned a generator object.gen.next()returned{ value: 1440, done: false }from ayield.- The generator body tries to call
gen.next()on itself. - The generator returned
closed at 1443and anothernext()is called. - A suspended generator receives
return("closed").
Place each visible situation in the generator state it represents.
State machines in transpiled code
Before a runtime supports generator or async syntax directly, a transpiler can rewrite the control flow. Tools such as regenerator and TypeScript's __generator helper use the same big idea: save locals in variables, save the resume point in a label, and use a switch statement to jump to the right part on each next().
function* lunchOrder() { const instruction = yield "cut fabric"; yield instruction === "rush" ? "stitch now" : "stitch tomorrow"; return "ready";} function createLunchOrderMachine() { let label = 0; let instruction; return { next(sent) { while (true) { switch (label) { case 0: label = 1; return { value: "cut fabric", done: false }; case 1: instruction = sent; label = 2; return { value: instruction === "rush" ? "stitch now" : "stitch tomorrow", done: false, }; case 2: label = 3; return { value: "ready", done: true }; default: return { value: undefined, done: true }; } } }, [Symbol.iterator]() { return this; }, };} function collect(iterator) { return [iterator.next(), iterator.next("rush"), iterator.next(), iterator.next()] .map((step) => String(step.value) + ":" + step.done) .join(" | ");} console.log(collect(lunchOrder()));console.log(collect(createLunchOrderMachine()));Lines 1 through 4 are the friendly syntax. Lines 7 through 29 are the visible machinery: label remembers where to resume, instruction stores a live local, and each return hands a result back to the caller. The lesson test drains both versions and proves they produce the same sequence.
Engines are free to use bytecode, interpreter registers, heap objects, continuations, and optimizing compiler IR. A transpiled switch machine is a precise control-flow model, not a memory-layout diagram.
Practical use
Suspended frames show up anywhere JavaScript waits without blocking the page. Async functions pause while a network request, permission prompt, stream read, or timer promise settles. Generators and async generators are useful when code pulls values over time. The practical rule is not “avoid suspension”; it is “know what is retained and measure before rewriting clear code.”
| Situation | What suspension gives you | Developer habit |
|---|---|---|
| Streaming data | Use async generators when values arrive over time. | Keep cleanup in finally and handle cancellation with return() or AbortSignal. |
| UI workflows | Use async functions to pause at network, animation, or permission boundaries. | Remember the function resumes later; stale DOM or stale state may have changed. |
| Debugging | Async stacks are reconstructed across suspension points. | Use the async-stack-traces lesson for debugger details; this lesson explains the paused frame. |
| Performance | Suspension allocates objects and schedules jobs, but clarity usually wins. | Measure first before replacing clear async code with manual callbacks or state machines. |
If a suspended async function closes over a large object, that object stays reachable until the function completes or becomes unreachable. That is expected, not automatically a leak. If memory matters, measure with the browser's memory tools and cancel work deliberately.
Common misconceptions
- “`yield` leaves the whole call stack active.” No. It returns to the caller; only the resumable generator state is retained.
- “`await` starts another thread.” No. It pauses the async function and resumes through promise jobs on the microtask queue.
- “A suspended local is a memory leak.” It is retained on purpose while it may be needed. It becomes a leak only if something keeps the generator or promise chain reachable forever.
- “Transpiled state machines are less real.” They are real JavaScript and a useful model; they just expose the labels and saved locals manually.
- “V8 bytecode names are JavaScript semantics.” They are evidence about this engine version. The semantics come from the ECMAScript behavior.
| Idea | What it means | Do not confuse it with |
|---|---|---|
| Generator syntax | Language feature that creates a generator object and yield points. | Not the same as every iterator; many iterators have no suspended stack frame. |
| Generator object | The heap object you call next() on; V8 prints it as JSGeneratorObject. | Not a normal call-stack frame that vanishes at the end of a call. |
| Async function | A function that returns a promise and can suspend at await. | Not a separate OS thread; the continuation runs through microtasks. |
| Transpiled state machine | A source-to-source model that stores labels and locals explicitly. | Not proof that every engine literally uses the same helper code. |
Practice exercises
Read the code and type the three printed values in order.
function* ticketCounter(start) {
let token = start;
while (token < start + 3) {
const command = yield token;
token += command === "skip" ? 2 : 1;
}
return "closed at " + token;
}
const tickets = ticketCounter(1440);
console.log(tickets.next().value);
console.log(tickets.next("skip").value);
console.log(tickets.next().value);The calls print 1440, 1442, and closed at 1443. The skip command advances the saved token by two before the second yield.
What message does the lesson's re-entrant generator print?
V8 throws TypeError: Generator is already running when a generator tries to resume itself while its body is active.
Finish the generator so it yields three items and then completes.
function* takeThree(items) {
// yield the first three items, then return "done"
}
const run = takeThree(["A", "B", "C", "D"]);
console.log(run.next().value);
console.log(run.next().value);
console.log(run.next().value);
console.log(run.next().value);function* takeThree(items) {
for (let i = 0; i < 3 && i < items.length; i += 1) {
yield items[i];
}
return "done";
}Each yield saves the loop index and the items binding. The fourth next() resumes after the loop and returns done.
next() bugWhy does "Asha" not become the name inside this generator?
function* greeter() {
const name = yield "ready";
return name ?? "no name";
}
const gen = greeter();
console.log(gen.next("Asha").value);
console.log(gen.next().value);The first argument is ignored. The first call prints ready; the second call resumes the yield with undefined, so it returns no name.
Which line prints immediately before the async function resumes?
let release;
const gate = new Promise((resolve) => { release = resolve; });
async function demo() {
const value = await gate;
console.log("async resumed " + value);
}
demo();
queueMicrotask(() => { console.log("settle promise"); release(41); });
queueMicrotask(() => console.log("already queued microtask"));The line immediately before async resumed 41 is already queued microtask. Resolving the promise queues the await continuation behind it.
Imagine a search box that starts a fetch on every change. Write two sentences explaining what the suspended async function retains and how you would avoid showing stale results.
In a search UI, an async handler may retain the query, controller, and DOM references until the fetch settles. Use AbortController or a request id to ignore stale results, and measure memory before replacing readable async code with manual callbacks.
Check your understanding
Question 1 of 8Why can a generator pause in the middle of its body?
Choose an answer to see the explanation.
Question 2 of 8What does this generator snippet print?
Read the code, then predictfunction* ticketCounter(start) { let token = start; while (token < start + 3) { const command = yield token; token += command === "skip" ? 2 : 1; } return "closed at " + token; } const tickets = ticketCounter(1440); console.log(tickets.next().value); console.log(tickets.next("skip").value); console.log(tickets.next().value);Choose an answer to see the explanation.
Question 3 of 8In spec terms, what state is a generator in while its body is running?
Choose an answer to see the explanation.
Question 4 of 8What does this first-
next()snippet print?Read the code, then predictfunction* greeter() { const name = yield "ready"; return name ?? "no name"; } const gen = greeter(); console.log(gen.next("Asha").value); console.log(gen.next().value);Choose an answer to see the explanation.
Question 5 of 8What proves that
awaitresumes from the microtask queue?Read the code, then predictlet release; const gate = new Promise((resolve) => { release = resolve; }); async function demo() { const value = await gate; console.log("async resumed " + value); } demo(); queueMicrotask(() => { console.log("settle promise"); release(41); }); queueMicrotask(() => console.log("already queued microtask"));Choose an answer to see the explanation.
Question 6 of 8Which V8 bytecode names are stable evidence for suspension in this lesson?
Choose an answer to see the explanation.
Question 7 of 8What is the main idea of a transpiled generator state machine?
Choose an answer to see the explanation.
Question 8 of 8What should production code do with this knowledge?
Choose an answer to see the explanation.
Key takeaways
- A normal call stack frame disappears on return; a suspended frame keeps live state and a resume point.
- A generator object is the handle that stores and resumes a paused generator body.
- V8 12.4 exposes stable suspend/resume bytecode names for both generators and async functions.
awaitstarts synchronously, suspends at the promise boundary, and resumes through microtasks.- Transpiled state machines make the same resume point visible with a
labeland aswitch.
Remember the one-liner.
Suspending a function means moving just enough of its frame out of the active stack so it can continue later.
Previous in this batch: Async stack traces. Up next: Exceptions & unwinding, where a thrown value stops normal execution by unwinding frames instead of saving them.