cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

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.

By the end, you can
  • 01
    Explain suspended framesDescribe what has to survive when a generator yields or an async function awaits instead of returning normally.
  • 02
    Drive a generator safelyPredict how next(), return(), and throw() move a generator through suspended-start, suspended-yield, executing, and completed states.
  • 03
    Connect 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.

Definition

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.

Real-life analogyA paused video remembers where you stopped

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.

Where frame state lives while code is paused
CaseWhile runningAfter it gives control back
Normal function callLives on the call stack while it runs.Destroyed when the function returns or throws.
Generator suspended at yieldLive locals, parameters, receiver, and resume point are retained by the generator object.Restored when next(), return(), or throw() resumes it.
Async function suspended at awaitThe continuation and live bindings survive while the awaited promise is pending.Resumed by a promise reaction job in the microtask queue.
Transpiled state machineA 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.

Generator frame walkthrough: save, yield, resume
Step 0 of 11Ready
Your turn: follow the blue line

Step through a generator frame as yield saves locals and the resume point, then next() restores them.

Running in
  1. script
Next: line 10
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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();
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Real-life analogyA lunch box set aside for later

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.

Generator bytecode probeJavaScript
function* ticketCounter(start) {  let token = start;  yield token;  token += 1;  return token;} const gen = ticketCounter(41);gen.next();gen.next();
Stable bytecode names asserted by the testShell
$ 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.

Node-only generator object probeJavaScript
function* ticketCounter(start) {  let token = start;  yield token;  token += 1;  return token;} const gen = ticketCounter(41);%DebugPrint(gen);gen.next();%DebugPrint(gen);
Stable %DebugPrint excerptText
DebugPrint: ... [JSGeneratorObject] - resume mode: .next() - continuation: 0 (suspended) - register file: ...DebugPrint: ... [JSGeneratorObject] - continuation: 1 (suspended)
Verified runtime fact

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.

Playground: drive a real generator object
Live generator used by the buttonsJavaScript
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");
Generator objectCreated, body not run
state modelsuspended-start
last actionnone yet
last resultwaiting

No calls yet. The generator body has not run.

Step 0 of 6suspended-start

Start from suspended-start. Press next() to enter the generator, then try sending skip, closing it with return(), or throwing into the paused yield.

The method calls are real JavaScript. The state label is a teaching model; this playground does not inspect your browser's engine.

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() closesPop out in the code editor (opens in a new tab)JavaScript
function* 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.

First next() has no receiver yield

The 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.

Await walkthrough: promise settles, frame resumes
Step 0 of 11Ready
Your turn: follow the blue line

Follow an async function that starts synchronously, suspends at await, and resumes from the microtask queue after its promise settles.

Running in
  1. script
Next: line 9
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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");
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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 bytecode probeJavaScript
async function fetchTicket(promise) {  const value = await promise;  return value + 1;} fetchTicket(Promise.resolve(41));
Stable async bytecode names asserted by the testShell
$ 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.

State behavior you can observePop out in the code editor (opens in a new tab)JavaScript
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.

Generator states from the ECMAScript model
StateMeaningResume behavior
suspended-startThe 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.
executingThe body is currently running.A re-entrant next()/return()/throw() throws TypeError: Generator is already running.
suspended-yieldThe body paused at a yield and saved its live bindings plus the continuation.A later resume call makes the yield expression finish.
completedThe body returned, ran off the end, or closed through return()/an uncaught throw.Further next() calls return { value: undefined, done: true }.
Sort the generator state
  • const gen = ticketCounter(1440); just returned a generator object.
  • gen.next() returned { value: 1440, done: false } from a yield.
  • The generator body tries to call gen.next() on itself.
  • The generator returned closed at 1443 and another next() is called.
  • A suspended generator receives return("closed").
Try it yourself
0 of 5 correct

Place each visible situation in the generator state it represents.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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().

A generator and a hand-written equivalent state machinePop out in the code editor (opens in a new tab)JavaScript
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.

A teaching model, not a claim about every engine

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.”

Where suspended frames matter in real applications
SituationWhat suspension gives youDeveloper habit
Streaming dataUse async generators when values arrive over time.Keep cleanup in finally and handle cancellation with return() or AbortSignal.
UI workflowsUse async functions to pause at network, animation, or permission boundaries.Remember the function resumes later; stale DOM or stale state may have changed.
DebuggingAsync stacks are reconstructed across suspension points.Use the async-stack-traces lesson for debugger details; this lesson explains the paused frame.
PerformanceSuspension 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.
Similar ideas that are easy to mix up
IdeaWhat it meansDo not confuse it with
Generator syntaxLanguage feature that creates a generator object and yield points.Not the same as every iterator; many iterators have no suspended stack frame.
Generator objectThe 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 functionA function that returns a promise and can suspend at await.Not a separate OS thread; the continuation runs through microtasks.
Transpiled state machineA source-to-source model that stores labels and locals explicitly.Not proof that every engine literally uses the same helper code.

Practice exercises

Exercise 1 · Warm-upPredict the ticket counter

Read the code and type the three printed values in order.

Starter codePop out in the code editor (opens in a new tab)JavaScript
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);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upName the re-entrant error

    What message does the lesson's re-entrant generator print?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeWrite a tiny generator

      Finish the generator so it yields three items and then completes.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      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);
        Exercise 4 · PracticeFind the first-next() bug

        Why does "Asha" not become the name inside this generator?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        function* greeter() {
          const name = yield "ready";
          return name ?? "no name";
        }
        const gen = greeter();
        console.log(gen.next("Asha").value);
        console.log(gen.next().value);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · PracticeRead async microtask order

          Which line prints immediately before the async function resumes?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          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"));

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeApply it to a real UI

            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.

              Check your understanding

              Suspended frames quiz · 8 questionsScore: first tries count
              1. Question 1 of 8Why can a generator pause in the middle of its body?

                Choose an answer to see the explanation.

              2. Question 2 of 8What does this generator snippet print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                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);

                Choose an answer to see the explanation.

              3. Question 3 of 8In spec terms, what state is a generator in while its body is running?

                Choose an answer to see the explanation.

              4. Question 4 of 8What does this first-next() snippet print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                function* 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.

              5. Question 5 of 8What proves that await resumes from the microtask queue?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                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"));

                Choose an answer to see the explanation.

              6. Question 6 of 8Which V8 bytecode names are stable evidence for suspension in this lesson?

                Choose an answer to see the explanation.

              7. Question 7 of 8What is the main idea of a transpiled generator state machine?

                Choose an answer to see the explanation.

              8. 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.
              • await starts synchronously, suspends at the promise boundary, and resumes through microtasks.
              • Transpiled state machines make the same resume point visible with a label and a switch.

              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.

              CompleteFrontend Clear concepts. Working examples.