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 what a paused function keepsSay what must be saved when a generator reaches
yieldor an async function reachesawait: its values and the line to continue from. - 02Drive a generator step by stepPredict what
next(),return(), andthrow()give back, and which of the four states the generator is in after each call. - 03Read the same steps as a state machineFollow a hand-written version that keeps a
labeland uses aswitch, like the code a transpiler writes.
Pause a function without losing it
A normal function call runs from top to bottom. It starts, runs its lines, and returns. When it returns, its local variables are thrown away.
Two kinds of functions can stop in the middle and continue later:
- A generator function pauses at each
yield. - An async function pauses at each
await.
While one of them is paused, other code runs. Later, it continues from the exact line where it stopped, with the same values in its variables.
A suspended frame is everything a paused function needs to continue later:
- its local variables and parameters,
- its
thisvalue, - the line to continue from, called the resume point.
This lesson shows how a JavaScript engine keeps that saved state. To review the code you write first, see generators, generators for control flow, async/await, iteration protocols, microtasks, and transpilers and polyfills. For how normal calls work, see the call stack and stack and heap.
You pause a video halfway through. Later, you press play. It continues from the same moment. It does not start again from the beginning.
- In real life: The pause mark keeps the moment you stopped
- In JavaScript: The resume point says which line runs next
- In real life: The video keeps your volume and subtitles
- In JavaScript: Local variables and parameters keep their values
- In real life: The video waits in your list
- In JavaScript: The paused function's saved state waits in memory
- In real life: Pressing play continues from there
- In JavaScript:
next()or a finished promise continues the function
Normal frames vs suspended frames
When you call a function, the engine makes a stack frame for it. A stack frame is the engine's notes for one running call. It holds:
- the line the call is running now,
- the call's local variables and parameters,
- where to go back to when the call returns.
The frames sit on the call stack, a pile of the calls that are running right now. In a normal call, the frame lives only while the call runs. When the function returns, its frame is removed from the pile.
A paused generator can't throw its frame away. In the next section, line 4 hands out a ticket number and pauses. When you call next() later, line 5 still needs the same token value. So the engine copies the values it still needs into an object that stays in memory. When the function continues, the engine builds a new stack frame from those saved values.
The table shows four kinds of calls. Read across each row: where the values live while the call runs, and what happens after it gives control back.
| Kind of call | Where are its values while it runs? | What happens after it gives control back? |
|---|---|---|
| A normal function call | In its stack frame, on the call stack. | When it returns or throws, the frame is removed and its values are gone. |
A generator paused at yield | In its stack frame while it runs. At yield, the values it still needs and the line to continue from are saved in the generator object. | next(), return(), or throw() continues it from the saved values. |
An async function paused at await | In its stack frame while it runs. At await, its values and the line to continue from are saved. | When the promise settles, a job in the microtask queue continues it. |
| A transpiled state machine | In normal variables, plus a label variable that remembers the step. | The next call uses a switch to jump to the right step. |
The rule that a paused function continues correctly comes from the JavaScript language itself. How the saved values are stored is up to each engine. This lesson looks at V8, the JavaScript engine inside Chrome and Node. The lesson's tests check the names V8 uses for these steps. They never check memory addresses, because those change on every run.
Saving a generator's frame in an object
A generator function is written with function*. Calling it does not run its body. Instead, it gives back a generator object. You run the body by calling next() on that object.
Inside the body, a yield expression sends a value out to the caller and pauses the body on that line. While the body is paused, it is not on the call stack. So the values it will need later are saved in the generator object.
The generator object lives on the heap. The heap is the part of memory where objects live. An object stays there as long as some code can still reach it.
Step through a generator. Watch yield pause the body and save token, then next() bring it back.
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 code. Line by line:
- Line 10 makes the generator object. The body has not run yet. Its state is
suspended-start, which means "made, but not started". - Line 11 calls
next()for the first time, so the body starts at the top. - Line 2 stores
1440intoken. - Line 4 yields
1440and pauses. The generator object keepstokenand the line to continue from. - Line 12 calls
next("skip"). The body continues at line 4. The pausedyieldfinishes, and its result is"skip", socommandis"skip". - Line 5 adds 2 to the same
token, so it becomes1442. Line 4 yields1442and pauses again. - Line 13 calls
next()with no value. Line 5 adds 1, sotokenis1443. The loop stops, and line 7 returns"closed at 1443". The generator is finished.
yield sends 1440 out to your code. The generator object keeps the rest: the value of token and the line to continue from.You eat half your lunch, close the box, and put it on a shelf. The food is not on the table, but it is not lost. Later you open the box and finish eating.
- In real life: Your name on the lunch box
- In JavaScript: The generator object, such as
tickets - In real life: The food stays inside the box
- In JavaScript: Local variables keep their values between calls
- In real life: You remember where you stopped eating
- In JavaScript: The resume point says which line runs next
- In real life: You open the box and keep eating
- In JavaScript:
next()brings the saved values back and continues
Seeing it in V8 with Node
V8 does not run your code exactly as you wrote it. First it turns each function into bytecode: a list of small, simple instructions. Node can print that list with the --print-bytecode flag.
The lesson test runs the code below in Node 22, which uses V8 12.4. It looks for three instruction names:
SwitchOnGeneratorStatejumps to the right line when the body starts or continues.SuspendGeneratorsaves the values into the generator object and pauses.ResumeGeneratorbrings the saved values back when the body continues.
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 ...You can also print the generator object itself. %DebugPrint is a V8 tool for looking inside values. It works only when Node starts with the --allow-natives-syntax flag. Line 9 prints the new generator object. Line 10 starts it, and line 11 prints it again while it is paused. The test looks for these words in the output:
JSGeneratorObjectis V8's name for a generator object.continuationis the resume point, stored as a number.register fileis where V8 keeps the saved local values.
function* ticketCounter(start) { let token = start; yield token; token += 1; return token;} const gen = ticketCounter(41);%DebugPrint(gen);gen.next();%DebugPrint(gen);DebugPrint: ... [JSGeneratorObject] - resume mode: .next() - continuation: 0 (suspended) - register file: ...DebugPrint: ... [JSGeneratorObject] - continuation: 1 (suspended)The tests check that the Node version starts with v22. and the V8 version starts with 12.4.. They never check memory addresses, the exact position of each instruction, or how many values are saved. Those can differ between computers, such as a Mac and a Linux server.
Resuming with next(), return(), and throw()
The generator object has three methods. Each one continues the paused body in a different way:
next(value)continues normally. After the first call,valuebecomes the result of the pausedyield.return(value)finishes the generator withvalue. Anyfinallyblock runs first.throw(error)continues by throwingerrorat the pausedyield. Atry/catchinside the generator can catch it.
tickets.next("skip") sends "skip" into the paused yield. The same token jumps from 1440 to 1442, and the generator pauses again.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.
The generator starts in suspended-start. Press next() to start the body. Then try sending "skip", finishing it with return(), or throwing an error at the paused yield.
Each button calls a real method on a real generator object. The results come from those calls. The state name is our own label, because browsers don't let code read a generator's state.
throw() goes to catch; return() finishesfunction* 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()));Line by line:
- Line 14 starts the generator. It pauses at line 3 and gives
"boarding". - Line 15 throws an error at the paused
yield. Thecatchblock on line 5 catches it, and line 6 yields"caught delay". - Line 16 continues. The
finallyblock printscleanup, and line 10 returns"done". - Line 17 calls
next()on a finished generator. It prints{"done":true}.JSON.stringifyleaves outvaluebecause it isundefined. - Lines 19 and 20 make a new generator, which pauses at
"boarding". - Line 21 calls
return("closed"). Thefinallyblock printscleanup, and the generator finishes with"closed". - Line 22 calls
next()on the finished generator and prints{"done":true}again.
next() ignores its valueThe body ignores the value you pass to the very first next(value). No yield is paused yet, so nothing can receive it. Send values on the second call or later.
How await suspends and resumes
An async function always returns a promise. A promise is an object that stands for a value that may arrive later. A promise settles when it gets its value or fails.
When you call an async function, its body starts right away, like a normal function. It runs until it reaches an await. If the promise after await has not settled yet, the function pauses there, and its frame is saved.
When the promise settles, JavaScript adds a job to the microtask queue. The microtask queue is a waiting line of small jobs. JavaScript runs them in order, as soon as the current script finishes. That job continues the paused function.
Follow an async function. It starts right away, pauses at await, and continues 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 order of the printed lines is the key. Line by line:
- Line 10 prints
script start. - Line 11 calls the async function. Line 2 prints
async startright away. - Line 3 pauses at
await, becausegatehas not settled. - Lines 13 and 14 put two jobs in the microtask queue. Line 15 prints
script end. - The first job prints
settle promiseand callsrelease(41). That settlesgateand puts the paused function at the back of the line. - The second job was already in line, so
already queued microtaskprints next. - Only then does the function continue. Line 4 prints
async resumed 41.
script end, the waiting jobs run in order. Settling the promise puts the paused function at the back of the line, so already queued microtask runs first.V8 pauses and continues an async function with the same instructions it uses for generators. The test prints the bytecode for the small async function below and looks for these names. _AsyncFunctionAwaitUncaught is V8's own helper for one await.
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 ...That is how V8 does it inside. The rule every engine follows is simpler: await pauses the async function, lets the current script finish, and continues from a microtask after the promise settles.
Generator objects and their state
The ECMAScript spec is the official rulebook for JavaScript. It says each generator object has a hidden state called [[GeneratorState]]. The double square brackets mean your code can't read it. The state is one of four values:
suspended-start: the object is made, but the body has not started.executing: the body is running right now.suspended-yield: the body is paused at ayield.completed: the generator is finished.
You can't read the state, but you can see it from how the generator behaves.
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 by line:
- Line 12 makes the generator. It is
suspended-start. - Line 13 starts the body. Now it is
executing. - Line 4, inside the body, calls
next()on the same generator. It is already running, so V8 throwsTypeError: Generator is already running. Line 6 prints that message. - Line 8 yields
"first pause". Now it issuspended-yield. Line 14 printsfirst pause false. - Line 15 continues the body, and line 9 returns
"finished". Now it iscompleted. Line 16 printsfinished true. - Line 17 calls
next()on the finished generator. Line 18 printsundefined true.
The table has one row per state. Read across each row: what the state means, and what happens when you call a method in that state.
| State | What does it mean? | What happens when you call a method? |
|---|---|---|
suspended-start | The generator object is made, but the body has not run yet. | The first next() starts the body. A value passed to this first call is ignored. |
executing | The body is running right now. | Calling next(), return(), or throw() from inside throws TypeError: Generator is already running. |
suspended-yield | The body is paused at a yield. Its values and the line to continue from are saved. | The next call continues the body, and the paused yield gets its result. |
completed | The generator is finished. It returned, ran off the end, was closed by return(), or threw an error it didn't catch. | Every later next() gives { value: undefined, done: true }. |
const gen = ticketCounter(1440);just gave back a generator object.gen.next()gave back{ value: 1440, done: false }from ayield.- The generator's body calls
gen.next()on its own generator. - The generator returned
closed at 1443, and thennext()is called again. - A paused generator gets
return("closed").
Put each moment in the generator state it is in at that time.
State machines in transpiled code
Some older browsers don't understand function* or async. A transpiler is a tool that rewrites new code into older code that runs everywhere. Tools such as regenerator and TypeScript's __generator helper turn a generator into a state machine. A state machine is an object that remembers which step it is on and does a different job for each step. It works like this:
- The saved local values live in normal variables.
- A variable, such as
label, remembers where to continue. - On each
next()call, aswitchstatement jumps to the right step.
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()));Line by line:
- Lines 1 to 5 are the generator, written the normal way.
- Lines 7 to 34 do the same job by hand. Line 8 makes
label, which remembers where to continue. Line 9 makesinstruction, the saved local value. - Line 13 switches on
label. Each case setslabelto the next step, then returns a result. - Line 18 saves the value sent in, just like the paused
yieldon line 2 receives it. - The
collectfunction on lines 36 to 40 callsnext()four times. It sends"rush"on the second call. - Lines 42 and 43 run
collecton both versions. Both print the same line:cut fabric:false | stitch now:false | ready:true | undefined:true.
next() call, label holds 1. The switch jumps to case 1, which returns "stitch now" when it is sent "rush".Engines don't have to work this way inside. They may use bytecode, saved values in a generator object, and other tricks. The switch version shows the steps in the right order. It does not show how memory is laid out.
Practical use
Paused functions show up whenever JavaScript waits without freezing the page. For example:
- An async function waits for a network request, a permission prompt, a stream read, or a timer.
- A generator, or an async generator (a generator that can also
await), hands out values one at a time, when the caller asks.
The useful habit is not to avoid pausing. It is to know what a paused function keeps, and to measure before you rewrite clear code.
Each row is a real situation. Read across: what pausing gives you there, and a good habit to keep.
| Where it happens | What does pausing give you? | What habit helps? |
|---|---|---|
| Streaming data | An async generator hands out each piece of data as it arrives. | Put cleanup in finally. Stop early with return() or an AbortSignal. |
| Page workflows | An async function waits for a network answer, an animation, or a permission prompt. | Remember the page may have changed while the function was paused. Check that its data is still current. |
| Debugging | The debugger rebuilds async stack traces across each place where a function paused. | See the async stack traces lesson for the debugger. This lesson explains what the paused function keeps. |
| Speed | Pausing makes a small object and a queued job. Clear code is usually worth that cost. | Measure first, before you replace clear async code with hand-written callbacks or state machines. |
A paused async function keeps every value it still needs. If it holds a large object, that object stays in memory until the function finishes, or until no code can reach the function any more. That is expected. It is not a memory leak by itself. If memory matters, measure it with the browser's memory tools, and cancel work you no longer need.
Common misconceptions
- "
yieldkeeps the whole call stack running." No.yieldhands control back to the caller. Only the generator's own saved values are kept. - "
awaitstarts another thread." No. A thread would be a second worker running code at the same time.awaitonly pauses the async function. It continues later from a microtask, on the same thread. - "A paused local variable is a memory leak." No. It is kept on purpose, because the function may still need it. It becomes a leak only if something keeps the generator or promise reachable forever.
- "Transpiled state machines are fake." No. They are real JavaScript that runs the same steps. They just show the saved values and the
labelin plain code. - "V8's instruction names are part of JavaScript." No. They show how this V8 version works. The rules of the language come from the ECMAScript spec.
These ideas are easy to mix up. Read across each row: what the idea is, and what it is not.
| Idea | What is it? | What is it not? |
|---|---|---|
Generator code (function*) | Code that makes a generator object and can pause at each yield. | It is not every iterator. Many iterators are plain objects with a next() method and never pause a function. |
| A generator object | The object you call next() on. It keeps the paused body's values. V8 prints it as JSGeneratorObject. | It is not a normal stack frame, which is removed when its call ends. |
| An async function | A function that returns a promise and can pause at each await. | It is not a second thread. It continues later from a microtask, on the same thread. |
| A transpiled state machine | Plain code that keeps a label and the saved values in variables. | It does not show exactly how every engine stores things inside. |
Practice exercises
Read the code. 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 adds 2 to the saved token before the second yield. The last call adds 1, the loop stops, and return gives closed at 1443.
In the generator states section, the body calls next() on its own generator. What error message does that code print?
V8 throws TypeError: Generator is already running. The body called next() on its own generator while that generator was still running.
Finish the generator. It should yield three items, then finish with "done".
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 pauses the loop. The generator object keeps i and items until the next call. The fourth next() continues after the loop and returns done.
next() bugWhy doesn't "Asha" 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 value is ignored. The first call runs to yield "ready" and prints ready. The second call sends nothing, so name is undefined, and the generator returns no name.
Which line prints just before the async function continues?
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 just before async resumed 41 is already queued microtask. Settling the promise puts the paused function behind that job.
A search box starts a fetch every time the text changes. Write two sentences. Say what the paused async function keeps, and how you would stop old results from showing.
In a search page, a paused async function keeps the search text, the AbortController, and any page elements it uses until the fetch finishes. Use AbortController to stop old requests, or give each request a number and ignore answers that aren't the newest. Measure memory before you replace clear async code with hand-written 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 the spec, 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 instruction names does the lesson's test look for?
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 8How should you use this knowledge in real code?
Choose an answer to see the explanation.
Key takeaways
- A normal stack frame is removed when its function returns. A suspended frame keeps the values the function still needs, plus the line to continue from.
- A generator object holds a paused generator's saved values. Calling
next(),return(), orthrow()on it continues the body. - In V8 12.4, generators and async functions use the same pause and continue instructions.
- An async function starts right away, pauses at
await, and continues from a microtask after the promise settles. - A transpiled state machine shows the same idea in plain code: a
labelvariable and aswitch.
Remember the one-liner.
To pause a function, the engine saves just enough of its frame outside the call stack, so the function can continue later.
Previous lesson: Async stack traces. Up next: Exceptions & unwinding. There, a thrown error stops functions by removing their frames, instead of saving them.