Iterators & generators
How the iteration protocol works
for...of, spread, array destructuring, Array.from, new Map(...), new Set(...), Promise.all, yield*: all of them are sugar over the same two-part contract.
- Iterable: an object with a
[Symbol.iterator]()method that returns… - Iterator: an object with
next()returning{ value, done }.
What for...of does under the hood:
const it = iterable[Symbol.iterator](); // once
for (let r = it.next(); !r.done; r = it.next()) {
use(r.value);
}
The split matters because the iterable holds no position; each iterator does. Two for...of loops over the same array never collide, since arr[Symbol.iterator]() mints a fresh iterator per call.
A contract detail people miss: when done is true, every built-in consumer discards the accompanying value. A generator's return x value never shows up in for...of or spread.
❌ "for...of works on any object." It throws TypeError: not iterable on a plain object. Objects don't implement the protocol, and the omission is by design: a bag of keyed properties has no one obvious "sequence of values", so you pick the view yourself with Object.keys/values/entries. (for...in walks keys of anything, including inherited ones. It is a different, older construct and has nothing to do with this protocol.)
One-shot iterators vs reusable iterables
function* g() { yield 1; yield 2; }
const gen = g();
[...gen]; // [1, 2]
[...gen]; // [] — silently
A generator object is its own iterator: its Symbol.iterator returns this. Drain it once and it's done forever; there is no rewind. Arrays, Maps, and strings re-iterate because their Symbol.iterator returns a new iterator each time. When you hand iteration state to consumers you don't control, hand them the factory (g) rather than the iterator (g()).
When does a generator start running?
function* gen() {
console.log("start");
yield 1;
}
const it = gen(); // logs NOTHING
it.next(); // "start", then { value: 1, done: false }
❌ "Calling a generator runs it up to the first yield." Calling runs zero body code. The function is created suspended at the top; each next() runs it to the next yield and pauses. That suspension is the entire feature: values are computed only when pulled, so infinite sequences are fine as long as the consumer stops.
function* naturals() { let n = 1; while (true) yield n++; }
function* take(it, n) { for (const x of it) { if (n-- <= 0) return; yield x; } }
[...take(naturals(), 3)]; // [1, 2, 3]
yield* delegates to any iterable, which gives you composition without building intermediate arrays. And since ES2024, iterators carry helpers directly: naturals().map(x => x * x).take(3).
How to make a class iterable
Hand-rolling an iterator object is boilerplate. A generator is an iterator factory, so the practical way to make a real data structure iterable is a generator method:
class LinkedList {
*[Symbol.iterator]() {
for (let node = this.head; node; node = node.next) yield node.value;
}
}
[...list]; // works; so do for...of, destructuring, new Set(list)…
Each call produces a fresh generator, so the class is a well-behaved reusable iterable. Recursion via yield* handles trees just as cleanly.
Passing values in with next(v)
yield is two-way: it sends a value out and evaluates to whatever comes back in.
function* dialog() {
const a = yield 1; // ← a is what the SECOND next() passes
yield a + 1;
}
const d = dialog();
d.next(99); // { value: 1 } — 99 goes nowhere
d.next(5); // { value: 6 } — the pending `yield 1` evaluates to 5
The rule: next(v) is the answer to the yield the generator is currently suspended at. Before the first next() nothing is suspended, no yield is waiting for an answer, so the first argument gets dropped. Interviewers probe this exchange because the off-by-one here is the most common generator mistake they see. What happens when the consumer stops early (return(), throw(), cleanup) is the senior half of this topic.
How return(), throw(), and try/finally clean up
An iterator may optionally implement return() and throw(). On generators they inject control flow at the suspension point: gen.return(v) behaves like a return v appearing at the paused yield; gen.throw(e) like a throw e there (catchable inside the generator). That makes try/finally around your yields a real cleanup guarantee:
function* rows(db) {
const cursor = db.open();
try {
while (cursor.more()) yield cursor.read();
} finally {
cursor.close();
}
}
for (const row of rows(db)) {
if (row.bad) break; // ← calls the generator's return() → finally runs
}
Every built-in consumer closes the iterator on early exit: break, a throw mid-loop, return from the enclosing function, even partial destructuring (const [first] = rows(db)). So a generator holding a resource is safe by default, provided the resource lives in try/finally. Two edge cases exist. A yield inside finally makes return() answer { done: false } and finish later. And once a generator is done, further next() calls return { value: undefined, done: true } without an error and without restarting.
❌ "break just stops pulling; the generator's finally runs whenever it gets GC'd." Cleanup is synchronous and immediate via return(); nothing waits for the garbage collector.
Why for await runs sequentially
Symbol.asyncIterator is the same protocol with one change: next() returns a promise of { value, done }. for await...of calls next(), awaits it, runs your body, then calls next() again; each continuation lands as a microtask (mechanics in the event-loop module).
The consequence people miss:
for await (const user of ids.map(id => fetchUser(id))) render(user); // parallel ✅
for (const id of ids) { const user = await fetchUser(id); ... } // sequential
❌ "for await parallelizes the awaits." It is strictly one at a time, and the protocol defines it that way; treating it as a limitation misreads the design. If the work items are independent, create the promises eagerly (as above, or Promise.all; see the promises module) and iterate the results. Reach for for await when the source itself is sequential: page N's response tells you page N+1's URL.
How async generators handle pagination
The "next page depends on this page" shape is exactly what async function* encapsulates. The cursor lives in the function body while consumers see a flat stream:
async function* commits(repo) {
let url = `https://api.github.com/repos/${repo}/commits`;
while (url) {
const res = await fetch(url);
url = parseNextLink(res.headers.get("Link"));
yield* await res.json(); // yield* works on plain arrays too
}
}
for await (const c of commits("nodejs/node")) {
if (tooOld(c)) break; // no further pages are ever fetched
}
Early exit works exactly like the sync case: break calls return() and pending try/finally runs, so put connection or reader release there. Note what the break buys you: pages are fetched only while the consumer keeps pulling, and nothing downloads eagerly.
Streams and backpressure
ReadableStream is async-iterable, so raw fetch bodies consume the same way:
const res = await fetch(url);
let size = 0;
for await (const chunk of res.body) size += chunk.length;
for await is pull-based. The consumer requests a chunk, processes it, then requests the next, so a slow consumer automatically slows the producer instead of buffering unboundedly. You get backpressure by construction, which is why async iteration is the natural surface for streams. Interviewers asking about streams listen for this pull model. (When to choose streams/SSE/WebSocket at all is the browser-apis module's territory.)
When are generators the right tool?
Genuine wins share one property: the sequence is expensive, unbounded, or arrives over time, so you want pull-based laziness.
- Paginated/cursor APIs behind a flat
for await(above). - Stream and chunk processing with backpressure.
Symbol.iteratoron real data structures (trees, linked lists, ranges) whereyield*recursion beats manual stack management.- Infinite or expensive sequences consumed partially (IDs, retries/backoff schedules, search-space exploration).
Notice these are mostly library-shaped code: you write the generator once so that many call sites get a plain loop. In everyday app code, a finite in-memory collection plus array methods is clearer than a generator pipeline, and a generator used as a fancy way to write what map/filter already says is cleverness, not engineering. Redux-Saga-style "generators as effect DSLs" has largely lost to async/await for the same reason: fewer people can read it than can write it. Seeing generators rarely in a healthy codebase is expected; the hard part is recognizing the three or four places where they beat every alternative.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.