Generators & Coroutines
A generator is a function that can pause itself and hand control back to its caller, then resume later from exactly where it left off. That single ability — suspendable execution — turns generators into the most flexible primitive in JavaScript for lazy sequences, two-way communication, custom iterators, and hand-rolled coroutines. Once you stop thinking of them as “weird functions that return values one at a time” and start thinking of them as resumable coroutines, a lot of advanced patterns fall into place.
How a generator pauses and resumes
You declare a generator with function* and pause it with yield. Calling a generator does not run its body — it returns a generator object that conforms to the iterator protocol. Execution only advances when you call .next(), and it runs up to the next yield, then freezes.
function* counter() {
console.log("start");
yield 1;
console.log("resumed");
yield 2;
console.log("done");
}
const gen = counter();
console.log(gen.next());
console.log(gen.next());
console.log(gen.next());
Output:
start
{ value: 1, done: false }
resumed
{ value: 2, done: false }
done
{ value: undefined, done: true }
Each .next() returns a { value, done } record. Because the generator is also iterable, you can drive it with for...of, spread, or destructuring — all of which stop automatically when done becomes true.
Two-way communication with next(value)
This is the feature that separates generators from plain iterators: yield is an expression, and whatever you pass to .next(value) becomes the result of the yield that the generator is currently paused on. The data flows both ways — out through yield, back in through next.
function* conversation() {
const name = yield "What is your name?";
const lang = yield `Hi ${name}, favourite language?`;
return `${name} loves ${lang}`;
}
const chat = conversation();
console.log(chat.next().value); // first prompt
console.log(chat.next("Ada").value); // answers name -> second prompt
console.log(chat.next("JS").value); // answers language -> return value
Output:
What is your name?
Hi Ada, favourite language?
Ada loves JS
The value passed to the first
.next()is silently discarded — there is noyieldwaiting to receive it yet. Always treat the firstnext()as “prime the generator”.
You can also inject control from outside: gen.throw(err) resumes the generator by raising an exception *at the paused yield* (catchable with try/catch inside the body), and gen.return(value) forces it to finish early, running any finally blocks for cleanup.
Delegation with yield*
yield* delegates to another iterable, yielding all of its values as if they were written inline. It also transparently forwards next(value) calls inward and propagates the delegated generator’s return value as the result of the yield* expression.
function* inner() {
yield "a";
yield "b";
return "inner-done";
}
function* outer() {
const result = yield* inner();
console.log("delegated returned:", result);
yield "c";
}
console.log([...outer()]);
Output:
delegated returned: inner-done
[ 'a', 'b', 'c' ]
Because yield* accepts any iterable — not just generators — you can flatten arrays, strings, Maps, or recursive structures cleanly:
function* flatten(arr) {
for (const item of arr) {
if (Array.isArray(item)) yield* flatten(item);
else yield item;
}
}
console.log([...flatten([1, [2, [3, [4]]], 5])]);
Output:
[ 1, 2, 3, 4, 5 ]
Infinite and lazy pipelines
Because a generator only computes the next value on demand, it can describe infinite sequences without ever exhausting memory. You compose small lazy operators and pull only what you need.
function* naturals() {
let n = 1;
while (true) yield n++;
}
function* map(iter, fn) {
for (const x of iter) yield fn(x);
}
function* take(iter, count) {
let i = 0;
for (const x of iter) {
if (i++ >= count) return;
yield x;
}
}
const squares = take(map(naturals(), (n) => n * n), 5);
console.log([...squares]);
Output:
[ 1, 4, 9, 16, 25 ]
Nothing in naturals() runs until take requests it, and take stops the whole pipeline at the count. This is the same pull-based model behind streaming libraries — generators give it to you with zero dependencies.
Modeling state machines
A generator’s suspended position is its state, which makes it a natural fit for state machines. Instead of tracking a currentState variable and a big switch, you let the linear control flow of the generator encode the transitions.
function* trafficLight() {
while (true) {
yield "green";
yield "yellow";
yield "red";
}
}
const light = trafficLight();
console.log(light.next().value, light.next().value, light.next().value, light.next().value);
Output:
green yellow red green
For input-driven machines, combine the loop with next(input) to react to events while the generator holds the current state implicitly between yields.
How generators relate to async
async/await is, conceptually, a generator whose yields are promises and whose .next() is driven automatically by the runtime. Before native async existed, libraries like co implemented exactly this: a generator yields a promise, an external runner waits for it, then feeds the resolved value back in with next(value).
function run(genFn) {
const it = genFn();
const step = (input) => {
const { value, done } = it.next(input);
if (done) return Promise.resolve(value);
return Promise.resolve(value).then(step);
};
return step();
}
run(function* () {
const a = yield Promise.resolve(10);
const b = yield Promise.resolve(a * 2);
console.log("total:", a + b);
});
Output:
total: 30
| Construct | Yields | Driven by | Returns |
|---|---|---|---|
function* | any value | manual .next() | iterator |
async function* | awaited values | for await...of | async iterator |
async function | (awaits promises) | the runtime | a promise |
For real async work today, reach for async/await or async function* (async generators) — the manual runner above is purely to show the relationship.
Best Practices
- Treat the first
.next()as priming the generator; never expect it to receive a value throughyield. - Use
yield*for delegation and recursion instead of manually looping and re-yielding another iterator. - Always include
try/finally(or rely ongen.return()) for cleanup, since consumers can abandon a generator early viabreakor.return(). - Build lazy pipelines from small, single-purpose generators (
map,filter,take) rather than one monolithic loop. - Prefer
async/awaitfor asynchronous flows; use generators when you need pausable, two-way, or infinite synchronous sequences. - Remember generators are single-use — once
doneistrue, create a fresh instance to iterate again.