Generators for control flow
Use generators as paused control-flow tools: send values with next, build a promise runner, clean up with finally, and coordinate cooperative coroutines.
- 01Hold a two-way conversationPredict how
next(value),throw(error), andreturn(value)resume a pausedyield. - 02Model async control flowStep through a tiny promise runner and compare the same sequence with
async/await. - 03Coordinate and clean upUse
finallyfor early exits and reason about cooperative coroutines without preemption.
Generators can pause more than iteration
You already know that a generator can produce values over time. This lesson uses the same pause-and-resume power for control flow: deciding who runs next, what value resumes a paused line, and what cleanup happens when the caller stops early.
A generator function, written with function*, does not run immediately. Calling it creates an iterator. Each next() resumes the body until the next yield, then returns an iterator result. The advanced part is that the caller can send a value back into the paused yield.
Imagine a walkie-talkie exchange: “What is your name?” Then silence. The other person replies, “Ada.” The first person continues from exactly that point using the answer. A generator conversation works the same way, except the pause point is a yield.
- In real life: One person speaks and waits
- In JavaScript: The generator reaches
yieldand pauses - In real life: The other person replies
- In JavaScript: The caller uses
next(answer) - In real life: The reply is heard where the first person paused
- In JavaScript: That answer becomes the value of the paused
yieldexpression
Where the analogy stops: A walkie-talkie has humans choosing words. A generator follows exact code, and the first next(value) has nowhere to deliver its value yet, so that first argument is ignored.
We will build from a tiny conversation to a promise runner, then use early exits, coroutines, and a state machine. The goal is not to replace async/await. It is to see why paused programs are powerful.
Sending values with next()
STEP THROUGHThe expression yield "question" does two jobs. First it sends "question" out to the caller. Later, when the caller resumes with next(answer), the whole yield expression evaluates to answer inside the generator.
The first call is special: it starts the body. Because the generator is not paused at a yield yet, the first next("ignored") argument is ignored. Step through the replay and switch the answers to prove it.
Predict each printed value. Step through how next(value) is a two-way conversation with the paused yield.
script
function* interview() { const name = yield "What is your name?"; const color = yield "Hi " + name + ", favorite color?"; return name + " likes " + color;}console.log(chat.next("ignored").value);console.log(chat.next("Ada").value);console.log(chat.next("blue").value);The value passed to next(v) becomes the result of the yield expression where the generator is currently paused. If the generator has not reached a yield yet, there is no expression to receive v.
The same idea powers throw(error) and return(value). throw(error) raises an error at the paused yield, where a try/catch inside the generator can handle it. return(value) asks the generator to finish, but it still runs finally blocks on the way out.
Generators + promises
INTERACTIVEBefore async/await became the standard way to write promise workflows, libraries such as co used generators to make asynchronous code read top to bottom. The generator yielded a promise. A runner waited for it. When it fulfilled, the runner called next(result); when it rejected, the runner called throw(error).
Picture a personal assistant: you pause your plan and hand over an errand. The assistant leaves, waits, and returns with either the result or the problem. Then you continue the plan from exactly where you stopped.
- In real life: You hand over an errand
- In JavaScript: The generator yields a promise
- In real life: The assistant waits
- In JavaScript: The runner waits for fulfillment or rejection
- In real life: The assistant brings the result back
- In JavaScript: The runner calls
next(result) - In real life: Bad news is brought back too
- In JavaScript: The runner calls
throw(error)
Where the analogy stops: This explains the control flow shape, not engine implementation. Modern async/await is specified directly by JavaScript, even though the model feels similar.
function run(genFn) { const it = genFn(); function step(method, value) { const result = it[method](value); if (result.done) return Promise.resolve(result.value); return Promise.resolve(result.value).then( (value) => step("next", value), (error) => step("throw", error) ); } return step("next");} function* loadProfile() { const user = yield getUser(); const posts = yield getPosts(user.id); return user.name + " has " + posts.length + " posts";}async function loadProfile() { const user = await getUser(); const posts = await getPosts(user.id); return user.name + " has " + posts.length + " posts";}The runner waits for each yielded promise, then sends the fulfilled value back with next(value).
co popularized before async/await. Modern JavaScript specifies async/await directly.The modern version is shorter because the language owns the runner. In everyday app code, prefer async & await. The generator runner is still worth learning because it reveals the handoff: pause, wait, send the value back, continue.
Cleanup with finally
INTERACTIVEA generator may hold a resource: a file-like reader, a lock, a subscription, or just a piece of state that must be cleaned up. Put cleanup in finally. If the caller stops early with return(), or a for...of loop breaks, the generator gets a chance to run that finally block.
function* readResource() { try { console.log("open"); yield "chunk 1"; yield "chunk 2"; } finally { console.log("close start"); yield "paused in cleanup"; console.log("closed"); }}open
close start
closed
first: {"value":"chunk 1","done":false}
return: {"value":"paused in cleanup","done":false}
next: {"value":"stop","done":true}
Calling return() begins cleanup. A yield inside finally can make cleanup pause again, so avoid yielding there in real resource cleanup.
yield inside finally to show the edge case. The lesson's advice is to avoid that pattern for real cleanup.The lab deliberately has a yield inside finally. That is legal, and it proves cleanup can pause again. It is also a trap: the resource may not be fully closed until the generator is resumed. Real cleanup code should usually finish without yielding.
This works with the same error handling ideas from try, catch & finally. The difference is that generators let the caller request an early exit through the iterator protocol.
Coroutines: taking turns on purpose
INTERACTIVEA coroutine is a routine that can cooperate with other routines by choosing when to pause. With generators, each task runs until it reaches yield. A scheduler can then resume a different task.
In chess, a player gets a turn, decides, moves, and only then does the other player act. Coroutines feel like that: each unit of work hands control back voluntarily.
- In real life: A player thinks and makes a move
- In JavaScript: A task runs until its next
yield - In real life: The clock passes to the other player
- In JavaScript: The scheduler resumes another generator
- In real life: No one grabs a piece mid-move
- In JavaScript: No task is interrupted halfway through synchronous work
Where the analogy stops: Real applications also have browser tasks, microtasks, rendering, workers, and operating-system scheduling. This lesson's scheduler is cooperative and single-threaded.
function* task(name, count) { for (let i = 1; i <= count; i++) { yield name + " step " + i; }} const tasks = [task("paint", 2), task("cook", 3), task("music", 2)];while (tasks.length > 0) { const current = tasks.shift(); const result = current.next(); if (!result.done) tasks.push(current);}function* trafficLight() { while (true) { yield "green"; yield "yellow"; yield "red"; }} const light = trafficLight();console.log(light.next().value);console.log(light.next().value);console.log(light.next().value);console.log(light.next().value);Click Run next turn to start.
Each task runs until its own yield, then the scheduler puts it at the back of the queue. No task is interrupted in the middle of a move.
This is not parallelism. It is a clear way to model turn-taking work, animation scripts, parsers, or teaching tools where pausing at named points is more useful than running everything at once.
Generators as state machines
A state machine moves through known states: green, yellow, red, then green again; or a game dialog that waits at each prompt. A generator makes that style readable because each yield is a named state, and each next() advances one step.
| Question | Generator control flow | Async function |
|---|---|---|
| Pause keyword | yield pauses a generator | await pauses an async function |
| Who resumes it | The caller uses next, throw, or return | The promise machinery resumes it when the awaited value settles |
| Result shape | Iterator results: { value, done } | A promise that fulfills or rejects |
| Best modern use | Iteration, custom control flow, teaching, cooperative scheduling | Production promise workflows |
Use this comparison to choose tools honestly. Generators are excellent for custom iteration, controlled replay, cooperative tasks, and little state machines. Promise workflows usually belong in async functions now.
Where you'll use this
SORTERGenerator control flow appears in libraries, test utilities, parsers, animation experiments, and interview questions. The practical skill is not “rewrite everything as a generator.” It is knowing when a pause point helps the code tell the truth.
- A wizard asks one question, waits for an answer, then asks the next
- Load JSON with
await fetch(...)in production app code - Turn cents into a price string immediately
- A toy scheduler lets tasks politely take turns
- Try a request, catch errors, and await a delay before retrying
- Add all numbers in an array and return the total
Sort each task by the simplest control-flow tool that fits it. The explanations call out when generators are useful and when they are overkill.
Common misconceptions
“The first next(value) sends value into the first yield.”
It cannot. The body has not reached a yield yet, so the first argument is ignored. The second call is the first one that can feed a paused expression.
“yield and await are the same thing.”
They both pause a function-like body, but they are different language features. yield talks to an iterator caller; await works inside async functions and resumes from promise settlement.
“A coroutine scheduler interrupts tasks whenever it wants.”
Not with generators. A task runs until it voluntarily reaches yield. Long synchronous work still blocks everything else.
“return() skips finally.”
return() asks a generator to finish, but finally blocks still run. That is why they are the right home for cleanup.
“The generator runner is how JavaScript engines implement await.”
No. It is an accurate model of the control flow, and libraries used it before async/await, but the language specifies async functions directly.
Practice: control the pause
5 EXERCISESPredict exactly what the program logs, in order.
function* demo() {
const answer = yield "question";
console.log(answer);
}
const it = demo();
console.log(it.next("first").value);
it.next("second");The first log prints the yielded value question. Then it.next("second") resumes the paused yield, so answer is second and the generator logs it.
Which word proves the generator ran its cleanup even though the loop stopped early?
function* files() {
try {
yield "file";
} finally {
console.log("closed");
}
}
for (const file of files()) {
console.log(file);
break;
}The loop prints file, then break closes the generator. That runs the finally block, so closed proves cleanup happened.
A tiny runner already uses next(value) for fulfilled promises. What method should it use to send a rejection back into the generator?
return Promise.resolve(result.value).then(
(value) => step("next", value),
(error) => step("throw", error)
);Use throw(error) for the rejection branch. That lets the generator's own try/catch handle the failure at the paused yield.
What order does the scheduler print?
function* one() {
yield "A1";
yield "A2";
}
function* two() {
yield "B1";
}
const queue = [one(), two()];
while (queue.length) {
const current = queue.shift();
const result = current.next();
if (!result.done) {
console.log(result.value);
queue.push(current);
}
}The first task yields A1 and goes to the back. The second yields B1 and goes to the back. Then the first task yields A2. The second task is done on its next turn, so nothing else prints.
For a real profile loader that awaits network promises, which tool should you usually choose today?
For loading promises in modern app code, choose an async function with await. The generator runner is excellent for understanding, but ordinary production promise workflows are clearer with the built-in syntax.
Quiz: check your understanding
7 QUESTIONSAnswer once from prediction, then read every explanation. The wrong answers are designed around common generator mistakes.
Question 1 of 7What happens to the argument passed to the very first
next(value)call?Choose an answer to see the explanation.
Question 2 of 7What does this two-way generator print?
Read the code, then predictfunction* demo() { const x = yield "ready"; console.log(x); } const it = demo(); console.log(it.next("ignored").value); it.next("Ada");Choose an answer to see the explanation.
Question 3 of 7In the promise runner, why does the success handler call
step("next", value)?Choose an answer to see the explanation.
Question 4 of 7How does
it.throw(error)behave for a paused generator?Choose an answer to see the explanation.
Question 5 of 7What does this early break print?
Read the code, then predictfunction* g() { try { yield "work"; } finally { console.log("cleanup"); } } for (const value of g()) { console.log(value); break; }Choose an answer to see the explanation.
Question 6 of 7What is the main rule for coroutines in this lesson's scheduler?
Choose an answer to see the explanation.
Question 7 of 7Which tool should you usually choose for everyday promise workflows now?
Choose an answer to see the explanation.
Key takeaways
next(value)sendsvalueinto the pausedyieldexpression; the very first argument is ignored.throw(error)raises an error at the pausedyield;return(value)finishes while still runningfinallyblocks.- A generator promise runner models “wait, resume with the result, or throw the error back,” but modern code usually uses
async/await. - Coroutines are cooperative: each task runs until it chooses to
yield. - Generators are useful for custom iteration, controlled replay, state machines, and small schedulers—not for every function.
Remember the one-liner.
A generator is a paused program the caller can resume with a value, an error, or an early finish.
Up next: Why modules?