Iterator separates how you walk a collection from how the collection stores its elements. It is so thoroughly absorbed into modern languages — for…of, generators, streams, IEnumerable — that most engineers use it dozens of times a day without naming it.
It is worth studying anyway, because two of its consequences show up constantly in production code: laziness, and what happens when the collection changes underneath you.
The problem it solves
Without it, every caller must know the storage:
// Array
for (let i = 0; i < list.length; i++) use(list[i]);
// Linked list
for (let node = head; node; node = node.next) use(node.value);
// Tree
walk(root, use);Change the storage and every caller changes. With an iterator the caller says only "give me the next one", and the collection decides what that means.
classDiagram
class Iterable {
<<interface>>
+iterator() Iterator
}
class Iterator {
<<interface>>
+next() Item
+hasNext() bool
}
class TreeCollection {
+iterator() Iterator
}
class DepthFirstIterator {
-stack: Node[]
+next() Item
+hasNext() bool
}
Iterable <|.. TreeCollection
Iterator <|.. DepthFirstIterator
TreeCollection ..> DepthFirstIterator : createsA collection may offer several iterators. A tree can hand out depth-first, breadth-first and leaves-only iterators — same data, different traversals, none of them the collection's own business to hard-code.
Laziness is the interesting part
An iterator does not have to have the elements yet. It only has to produce the next one when asked, which means it can represent a sequence that is expensive, infinite, or not fully known.
function* readLines(file) {
let buffer = '';
for (const chunk of file.chunks()) {
buffer += chunk;
let i;
while ((i = buffer.indexOf('\n')) >= 0) {
yield buffer.slice(0, i);
buffer = buffer.slice(i + 1);
}
}
if (buffer) yield buffer;
}
for (const line of readLines(hugeFile)) {
if (line.includes('ERROR')) { console.log(line); break; }
}A 40 GB file is never in memory, and the loop stops reading as soon as it finds what it wanted. The eager version — read all lines into an array, then filter — is a different program with a different memory profile, and the two look nearly identical in source.
That is the practical payoff: laziness composes. filter, map and take over a lazy sequence do one pass and stop early; over eager collections they build an intermediate array at every step.
The invalidation trap
Modifying a collection while iterating it is the pattern's classic bug, and the behaviours differ dangerously between languages.
for (const item of items) {
if (item.expired) items.splice(items.indexOf(item), 1); // skips elements
}Removing an element shifts everything after it down, so the iterator's next position skips one. The loop appears to work — it removes some expired items — which is far worse than crashing, because the bug is silent and data-dependent.
Three defensible approaches:
- Fail fast. Detect modification and throw. Loud, immediate, and it is what several standard libraries do.
- Iterate a snapshot. Copy first, then mutate the original. Simple and correct; costs a copy.
- Build a new collection.
items = items.filter(i => !i.expired). Usually the clearest, and it sidesteps the question entirely.
Silently skipping is the only unacceptable outcome, and it is what the naive code above does.
Pagination is Iterator over a network
An API returning a page and a cursor is exactly this pattern with a network between next() calls, and it inherits every consideration above plus a few of its own.
- Laziness is mandatory. Nobody materialises ten million rows.
- The underlying data changes between pages. Offset-based pagination will skip or duplicate rows when items are inserted or deleted mid-traversal — the invalidation trap, distributed. Cursor-based pagination over a stable sort key is the fix.
- The iterator holds server state or does not. A cursor encoding a position is stateless and survives restarts; a server-side cursor is more powerful and must be expired.
- Cleanup has no guarantee. A client that stops paging never tells you, so any server-side resource needs a timeout.
When you would write one by hand
Almost never for ordinary collections — the language's own facilities are better. The cases that remain:
- A custom traversal order over a structure you own, especially a tree with several meaningful orders.
- An expensive or remote sequence you want to expose as an ordinary loop: paginated API results, database cursors, streamed files.
- An infinite or generated sequence — retry backoff intervals, identifiers, simulation steps.
- Hiding a composite structure, so callers walk a tree of nested collections as one flat sequence.
Check your understanding
Common mistakes
Mutating a collection while iterating it. The silent-skip variant is the dangerous one.
Letting a lazy sequence escape the scope owning its resource. The file handle or connection stays open until something disposes it.
Breaking out of a lazy loop without cleanup. Early termination is the point; leaking the resource is not.
Offset pagination over changing data. Rows are skipped and duplicated, and the report is quietly wrong.
Writing an iterator where the language has one. Generators and built-in protocols are better in every respect.
Iterators that expose internal nodes. Yielding a linked-list node rather than its value re-couples callers to the storage the pattern was hiding.
Assuming an iterator is restartable. Most are single-pass, and passing one to two consumers gives the second an empty sequence.
Practice
Design an iterator over a paginated third-party API so callers can write an ordinary loop. State what you expose, what you hide, and the failure modes you must handle.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
The whole point is that callers do not see pages. Decide what happens when a page request fails mid-traversal.
Show a strong answer
What callers see: a sequence of items. No pages, no cursors, no page size. for (const invoice of api.invoices({ since })) — the pagination is entirely the iterator's business, which is the pattern's contribution.
What the iterator hides: the cursor, the page size, the request retries, and the rate limiting. A caller that has to know any of those has not been given an iterator, it has been given a paging helper with extra steps.
Failure modes I must handle, since this is where a naive implementation breaks:
- A page request fails mid-traversal. This is the important one. The caller has already consumed 300 items and the 4th page returns a 503. Silently ending the sequence is unacceptable — the caller cannot distinguish "that is all the invoices" from "the API broke", and will happily reconcile against an incomplete set. Throw. Partial success must not look like completion.
- Rate limiting. Retry with backoff inside the iterator, since the caller cannot do anything useful with a 429. Retries belong here because the operation is idempotent and the caller has no context to handle it.
- The cursor expires part way through a long traversal. Detect the specific error and fail clearly, because silently restarting would return items the caller has already seen and possibly loop forever.
- The caller stops early. With a generator,
breaktriggers the disposal path, so I would release anything held there. There is no server-side state in a stateless cursor design, which is a good reason to prefer one.
Design choices worth stating:
- Prefetch at most one page ahead, not more. It hides most of the latency without turning a
take(5)into a hundred-item download. - Do not retry indefinitely. A bounded number of attempts, then fail. An iterator that blocks forever is worse than one that throws, because the caller has no timeout of its own.
- Expose the underlying page count or cursor as an optional diagnostic, not as part of the main interface — enough for logging without inviting callers to depend on it.
What I would document loudly: the sequence is single-pass and not restartable, and iterating it twice will make the requests twice. Callers who need the items more than once should materialise them, and should be told the count could be large.
A team has code that removes expired items in place while iterating, and it has been quietly missing items for months. Explain the bug precisely, then argue for the fix you would ship and why not the others.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Work through a concrete four-element example to show the skip.
Show a strong answer
The bug, concretely. Take [a, b(expired), c(expired), d]. The iterator is at index 1, holding b. Removing b shifts c to index 1 and d to index 2. The iterator advances to index 2 — which is now d. c is never visited and never removed. Two adjacent expired items always leave the second behind; three leave two. The number missed depends entirely on how the expired items happen to be distributed, which is why this looks intermittent and why it survived months of review.
What made it invisible: the loop does remove expired items, just not all of them. Tests written with a single expired item pass. Tests with non-adjacent expired items pass. It takes adjacency to fail, and nobody wrote that test because nobody was thinking about the mechanism.
The fix I would ship: build a new collection.
items = items.filter((i) => !i.expired);- It is correct by construction — there is no traversal position to invalidate, so the class of bug does not exist rather than being handled.
- It reads as what it means: keep the ones that are not expired.
- The allocation is irrelevant next to a correctness bug, and if profiling ever says otherwise, that is a measurement rather than an assumption.
Why not iterate a snapshot (for (const i of [...items]) then remove): it works, and it leaves the removal-by-index code in place — code whose correctness now depends on a copy being made somewhere else in the loop header. The next person to touch it will not know that, and it re-breaks. It fixes the instance rather than the class.
Why not fail fast (detect modification and throw): this is the right behaviour for a library to provide, and it would have surfaced the bug months ago. But as a fix it only converts a silent wrong answer into a crash — I would still have to write the filter. Worth adding to the collection type if it is ours, as a defence for the next occurrence, but it is not the fix.
What I would do beyond the fix:
- Write the adjacent-expired-items test first, confirm it fails, then fix. Otherwise there is no evidence the fix addresses the actual defect.
- Search for the same shape elsewhere —
splice,removeordeleteinside a loop over the same collection. This idiom rarely appears once. - Quantify the damage. Items that should have expired have been live for months; someone needs to know how many and whether it mattered.
Key takeaways
- Iterator separates traversal from storage, so callers do not depend on how a collection holds its elements, and one collection can offer several orders.
- Laziness is the interesting consequence: bounded memory, infinite sequences, genuine early termination, and one-pass composition of
map/filter/take. - A lazy sequence holds resources open. Do not let it escape the scope that owns them, and honour the disposal path on early exit.
- Never mutate a collection while iterating it. Silently skipping elements is the worst outcome and the most likely one.
- Build a new collection instead — correct by construction, and the allocation almost never matters.
- Pagination is Iterator over a network, and offset-based paging inherits the invalidation trap. Use cursors over a stable sort key.
- A paginated iterator must throw rather than end silently when a page fails, or partial results look like complete ones.
Further reading
- Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994).
- Barbara Liskov and John Guttag, Program Development in Java (Addison-Wesley, 2000) — iteration abstractions and their specifications.
- Joshua Bloch, Effective Java, third edition (Addison-Wesley, 2018) — the for-each loop and concurrent modification.
- Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002).
- Philip Wadler, "Deforestation: Transforming Programs to Eliminate Trees" (Theoretical Computer Science, 1990) — why lazy composition avoids intermediate collections.