Iterator
The comment thread was stored as a tree. Nested objects, each with a replies array, replies inside replies, a few levels deep on a busy post. Nothing exotic.
The trouble was that everyone who needed to walk it wrote the walk themselves. Word count did its own recursion. Search did its own recursion. The “collapse everything below score zero” feature did a third one. Here are two of them, and you can see the third without me typing it:
function countWords(node) {
let total = node.text.split(/\s+/).length;
for (const reply of node.replies) {
total += countWords(reply);
}
return total;
}
function findById(node, id) {
if (node.id === id) return node;
for (const reply of node.replies) {
const hit = findById(reply, id);
if (hit) return hit;
}
return null;
}
Every one of those functions knows three things it has no business knowing: children live on a property called replies, that property is an array, and you visit a node before its children. Storage detail, storage detail, traversal order. Baked into a dozen call sites.
Then the thread got big enough that we paged the deep replies in from the server on demand, so replies stopped being a plain array. Every walker broke at once. Not because counting words got harder. Because counting words was welded to how the tree happened to be stored on a Tuesday in 2023.
The coupling, not the recursion
The recursion was never the problem. The problem is that “how do I visit every node” got copy-pasted into code that only cares about “what do I do with each node”. Those are two different jobs, and they were fused.
Pull them apart. One piece knows how to produce the nodes in order. Everyone else just receives nodes and does their thing. The consumer asks “give me the next one” and “are we done”, and it never sees the replies array, never sees the recursion, never learns whether the answer came from memory or a network page.
That separation is the whole iterator pattern. Put a uniform way to walk a collection in front of it, so the traversal lives in one place and the storage can change underneath without every caller finding out.
If you have read a Java or C# design patterns book, this is where it hands you an Iterator interface with hasNext() and next(), a Collection interface with createIterator(), and a page of class diagrams. Hold that thought. In JavaScript almost all of that ceremony is already in the language, and you have been using it since your first for...of.
JavaScript already speaks this
The pattern needs two roles. Something that can produce an iterator on request, and the iterator itself that hands out one value at a time. JavaScript names both with a protocol, not a class you inherit from.
An object is iterable if it has a method keyed by Symbol.iterator that returns an iterator. An iterator is any object with a next() method that returns a record with two fields: value (the next item) and done (a boolean that flips to true when there is nothing left). That is the entire interface. Two methods, one plain record.
You have been consuming this protocol constantly. Arrays, strings, Map, Set, arguments, a NodeList from querySelectorAll, the parts of a URLSearchParams: every one of them ships a Symbol.iterator, which is exactly why for...of and spread work on all of them and why [...new Set(a)] dedupes an array. The mechanics of the protocol and the Symbol keys that hang built-in behaviour off an object are their own lessons. What matters here is the shape of the idea: the collection publishes one contract, and consumers depend on the contract instead of the internals.
Writing an iterator by hand
To feel that your own object is no different from a built-in one, make something iterable from scratch. A lazy range is the smallest honest example. No array of numbers exists anywhere; the iterator computes each one as it is asked.
function range(start, end, step = 1) {
return {
[Symbol.iterator]() {
let n = start;
return {
next() {
if (n >= end) return { value: undefined, done: true };
const value = n;
n += step;
return { value, done: false };
},
};
},
};
}
That is the raw pattern, spelled out. range is the iterable; calling its Symbol.iterator method returns a fresh iterator that closes over its own n. Now every consuming syntax in the language works on it, and none of them know or care that there is no underlying array:
for (const n of range(0, 5)) console.log(n); // 0 1 2 3 4
[...range(0, 10, 2)]; // [0, 2, 4, 6, 8]
const [first, second] = range(100, 200, 10); // first = 100, second = 110
That next() body is all bookkeeping: manual position tracking, a hand-built result record, a done flag you must remember to set. Nobody writes it this way, because a language feature generates the whole thing for you.
Generators write the iterator for you
A generator function (function*) returns an iterator when you call it, and yield produces the values. The engine builds the next() method, the { value, done } records, and the pause-and-resume position tracking. You write the traversal as if it were an ordinary loop.
The same range, now four lines with no bookkeeping:
function* range(start, end, step = 1) {
for (let n = start; n < end; n += step) {
yield n;
}
}
Every consumer from before still works, byte for byte, because a generator object is an iterator (it has next) and is also iterable (it has a Symbol.iterator that returns itself). The two versions are indistinguishable from the outside.
Change the bounds below and watch one custom object feed a for...of loop, a spread, and a destructure. There is no array of numbers anywhere; each value is minted the instant something pulls it.
Now the tree from the opening. This is where generators stop being a nicer range and start earning their place, because yield* delegates to another iterator, and a recursive walk is exactly a generator that delegates to itself:
function* walk(node) {
yield node;
for (const reply of node.replies) {
yield* walk(reply); // hand off to the child's walk, splice its values in
}
}
Four lines, and that is the only depth-first traversal in the entire application. countWords, findById, and the collapse feature stop knowing how the tree is shaped. They consume nodes:
// word count is now a fold over the node stream
let words = 0;
for (const node of walk(thread)) {
words += node.text.split(/\s+/).length;
}
// search is find over the node stream
const target = [...walk(thread)].find((node) => node.id === 42);
Because walk(thread) is itself an iterator, the lazy iterator helper methods hang straight off it too, so walk(thread).map(wordsIn).reduce(add) folds the whole tree without building an array in between.
Swap the storage, keep the callers
Here is the payoff the opening was missing. Suppose the thread is no longer nested objects at all. It arrived from the API as a flat list of rows with parent pointers, a shape that is friendlier to store and to send:
const rows = [
{ id: 1, parent: null, text: "ship it" },
{ id: 2, parent: 1, text: "tests?" },
{ id: 4, parent: 2, text: "green" },
{ id: 3, parent: 1, text: "lgtm" },
];
Completely different storage. The traversal changes to match. Everything downstream does not.
function commentThread(rows) {
const childrenOf = Map.groupBy(rows, (r) => r.parent);
return {
*[Symbol.iterator]() {
function* walk(node) {
yield node;
for (const child of childrenOf.get(node.id) ?? []) {
yield* walk(child);
}
}
const roots = childrenOf.get(null) ?? [];
for (const root of roots) yield* walk(root);
},
};
}
countWords, findById, the collapse feature, the tests: not one line of them moves. They asked for nodes, they still get nodes, in the same order. The seam the iterator pattern cut is holding exactly where you wanted it. This is the difference between “we rewrote the walk” and “we rewrote the walk and forty call sites and shipped three regressions doing it”.
Laziness is the real prize
Everything so far is about decoupling, which is the classic reason the pattern exists. JavaScript throws in a second reason that the textbook version never had, and it is the one you will reach for most: an iterator computes each value only when asked, so it can describe a sequence that no array could ever hold.
An infinite one, for instance. This is a perfectly good, terminating program:
function* ids() {
let n = 1;
while (true) {
yield `c_${n++}`;
}
}
const it = ids();
it.next().value; // "c_1"
it.next().value; // "c_2"
The while (true) never runs to completion. It runs exactly as far as you pull it and then parks, holding its place, using nothing. Pull three values and three values were computed. The other infinity was never touched because it was never asked for.
Pull whatever number you like from an endless generator and watch the compute counter move by exactly that many. Between pulls, the generator is frozen mid-loop, costing nothing:
Pipelines that never build the middle
The everyday version of laziness is not infinity, it is not paying for work you throw away. Say you want the first three active users out of a big list, and deciding “active” is expensive. The array-method version does all the work and then discards almost all of it:
// eager: decorate a million rows, filter a million rows, keep three
const firstThree = users
.map(expensiveDecorate) // 1,000,000 calls
.filter(isActive) // walks the whole decorated array
.slice(0, 3); // ...to keep three
Each array method materialises a full intermediate array before the next one starts. The same pipeline written against the iterator pulls values through one at a time and stops the instant take(3) is satisfied:
// lazy: pull until three survive, decorate only what you pull
const firstThree = users
.values() // arrays hand you an iterator too
.map(expensiveDecorate)
.filter(isActive)
.take(3)
.toArray(); // maybe a handful of decorate calls, not a million
Same reading order, wildly different cost. map, filter, take, drop, flatMap and friends now live on Iterator.prototype and are lazy by construction, shipped and Baseline across current browsers and Node. If you hold some other iterator that predates these methods, Iterator.from(thing) wraps it so the helpers apply.
Cleanup when the loop quits early
There is a third method in the protocol that most people never touch, and it matters the moment your iterator owns a resource. Alongside next(), an iterator may have return(). When a for...of loop ends early, on a break, a return, or a thrown error, the language calls return() on the iterator to let it clean up.
Generators wire this up for free through try/finally. Put the cleanup in a finally and it runs whether the consumer drains the sequence or bails after the first match:
function* readLines(file) {
const handle = openSync(file);
try {
let line;
while ((line = handle.readLine()) !== null) {
yield line;
}
} finally {
handle.close(); // runs on normal end AND on early break
}
}
for (const line of readLines("huge.log")) {
if (line.includes("PANIC")) break; // finally still fires, file still closes
}
That break looks like it abandons the generator halfway through an open file. It does not leak. The loop calls the generator’s return(), execution resumes at the yield as if a return were injected there, the finally runs, and the handle closes. This is a real advantage of modelling a resource as an iterator: acquisition and release sit together, and the release fires no matter how the consumer stops reading.
Data that arrives over time
Everything above pulls values that already exist or can be computed instantly. Pull a value that has to come over the network and next() would have to block, which JavaScript does not do. So there is a parallel protocol for values that arrive later: Symbol.asyncIterator, an iterator whose next() returns a promise, and for await...of to consume it. An async generator writes one as easily as function* writes the sync kind, and it is the natural shape for a paginated endpoint. You yield rows, and the fact that a new page gets fetched every hundredth row is hidden inside the generator, exactly the way the tree hid its replies array:
async function* allUsers() {
let cursor = null;
do {
const page = await api.get("/users", { cursor });
yield* page.items; // caller sees a flat stream of users
cursor = page.nextCursor;
} while (cursor);
}
for await (const user of allUsers()) {
// one user at a time; pages fetched lazily as you consume
}
Node’s file and network streams are async iterables too, so for await (const chunk of readable) reads a stream chunk by chunk with backpressure handled for you. The broader Streams API is the same idea with explicit control over buffering and piping. Same pattern, one clock tick later.
When an array is the right answer
The pattern is built into the language, which makes it tempting to reach for iterators everywhere. Resist that. Most of the time an array is the correct data structure and it is already iterable, so wrapping it buys nothing but indirection.
If you already have an array, you already have an iterator. Do not build a custom iterable to walk a fixed, in-memory list of thirty items. for...of and the array methods are right there. The pattern earns its keep when traversal is non-trivial (a tree, a graph, a cursor), when the storage might change, or when laziness saves real work. A ten-element array is none of those.
Laziness has a bill, and small collections do not clear it. A lazy pipeline is harder to log (there is no intermediate array to inspect), you cannot index into it or ask its length, and errors surface when a value is pulled rather than where the pipeline was defined. For three items you traded readability for a saving of nothing. Reach for lazy iterators when the sequence is large, infinite, or expensive per item. Otherwise array.map().filter() is clearer and the intermediate arrays cost nothing you will notice.
Do not make a domain object iterable just because you can. A User is not a sequence. Giving it a Symbol.iterator so someone can spread it is a party trick that will confuse every reader. Iterables should be things that genuinely are collections or streams of items. If for...of thing would make a maintainer ask “wait, what does that even yield”, the object should not be iterable.
Summary
- The iterator pattern splits how a collection is stored from how it is traversed, so consumers depend on one walking contract instead of reaching into internals. Change the storage and the callers do not move.
- JavaScript ships the pattern as a protocol, not a base class. An iterable has a
Symbol.iteratormethod that returns an iterator; the iterator’snext()returnsvalueanddone.for...of, spread, and destructuring all speak it, which is why they already work on arrays, strings,Map, andSet. - You can hand-write the iterator with a
next()method, but almost nobody does. A generator (function*withyield, andyield*to delegate) writes the whole{ value, done }machine for you, which turns a recursive tree walk into four lines. - The feature the textbook pattern never had is laziness: values are computed on pull, so you can model an infinite sequence, stream a huge dataset without loading it, and build iterator-helper pipelines (
map,filter,take) that never materialise the middle. Baseline across current browsers and Node. - Model a resource as a generator and put cleanup in
finally; an earlybreaktriggers the iterator’sreturn(), so the handle closes whether the consumer finishes or bails. - For data that arrives over time, the async twin is
Symbol.asyncIteratorwith async generators andfor await...of, ideal for paginated fetches and streams. - Use it when traversal is non-trivial, the storage may change, or laziness saves real work (large, infinite, or expensive sequences). Avoid it when you already hold an array of a handful of items; wrapping that in a custom iterable or a lazy pipeline adds indirection and buys nothing.
- Remember the one-shot trap: an iterator drains after one pass, an iterable mints a fresh iterator each time. Expose the iterable when a sequence may be walked more than once.