01 / The idea
The caller wants entries. The source has pages.
When five audit entries already live in an array, a normal loop is enough. If an export needs every row and the dataset is small, fetching all pages before processing them can also be a clear design.
The pressure changes when the log grows or the consumer stops after a few entries. Fetching everything first performs requests and retains rows the caller may never use. Putting pagination directly into each consumer repeats the continuation, buffer, and failure rules.
Iterator gives the caller a way to traverse values without exposing how the source stores or reaches them. Our cursor owns the current page and the position within it. The caller asks for the next entry and receives an entry, exhaustion, or a failure.
A generator is one way to implement that protocol. It can suspend at a yield and resume with its local state intact. An explicit iterator stores that progress in fields instead. The responsibility is the same even when the language’s loop syntax differs.
02 / See the shape
Keep the position with the traversal.
The Basic form walks through existing rows: a TypeScript generator, a Go sequence callback. The data is already in memory; the form shows how each language’s loop machinery reaches it.
In the wild adds an audit-log cursor over a deterministic sample source. The source returns a page of entries and a continuation. The cursor remembers its unread buffer, requests another page only when that buffer runs out, and owns the session it opens.
| Participant | Owns | Does not need to know |
|---|---|---|
| Consumer | What to do with each entry and when it has enough. | Which page contains that entry. |
| Iterator | Buffer position, continuation, and its session lifetime. | Why the caller wants the entry. |
| Source session | Fetching one page and releasing its resource. | How the caller processes rows. |
Traverse rows already in memory: a TypeScript generator or a Go yield callback. The consumer can stop early.
export function* entries(rows: readonly AuditEntry[]): Generator<AuditEntry, void, unknown> {
for (const row of rows) yield { ...row };
} func Entries(rows []AuditEntry) iter.Seq[AuditEntry] {
return func(yield func(AuditEntry) bool) {
for _, row := range rows {
if !yield(row) {
return
}
}
}
} The call site reads evt-1, evt-2, and evt-3, then breaks. Two rows fit on a page, so this requires two page fetches. Four rows have been fetched, three have been delivered, and the session closes once. The fourth fetched row is discarded unread; the fifth row’s page is never requested.
The shared contract and its boundariesWhat Next, Close, and the sample source promise
Constructing the cursor opens and fetches nothing. The first Next opens a session. Each successful Next delivers one copied entry in source order. It reads the current buffer before fetching another page. A single Next may cross several empty pages when their continuations indicate that more data exists.
The sample returns a finite sequence with valid continuations. Its Open and Close operations cannot fail; Close is idempotent. The only injected failure is a selected page fetch. Everything runs synchronously in memory, with no prefetch, automatic retry, or continuation-cycle detection.
The iterator preserves each entry’s strings, including empty or multiline content. Interpreting or validating audit fields belongs to the source adapter or the consumer’s domain rules. The sample source owns a stable fixture: TypeScript and Go copy input pages. Delivered rows and inspection collections do not expose mutable retained state.
The cursor retains at most one fetched page buffer. The fixture itself remains in memory, and the lab keeps delivered entries so you can inspect them.
Reading the TypeScriptIterableIterator, yield, and return
next() returns an object with done and value,
which is undefined once done. The cursor’s [Symbol.iterator]() returns itself,
so a for-of loop can consume the same traversal. A fresh cursor starts a fresh traversal;
looping over the same finished cursor does not rewind it.
The basic function* yields copied rows. The practical generator adapter
uses yield* cursor to delegate traversal, with Close in a finally block.
Calling the generator function creates a generator object; its body starts when it is
advanced. The explicit cursor fields make the same progress visible to the lab. Read the generator contract.
On a for-of break, iterator closing calls the available return() method. Our return closes the session. Manual calls to Next still need an explicit return
or Close when the caller stops; abandoning a reference is not this cleanup protocol. Fetch
failures are caught inside Next so they close the session before being rethrown. See ECMAScript’s IteratorClose operation.
Reading the GoA pull cursor and a range-compatible adapter
The explicit cursor returns (AuditEntry, bool, error). The boolean says
whether an entry exists; the error reports a failed fetch. Check the error before
treating false as success. A manually driven cursor needs a deferred Close around the
consumer’s work.
AuditEvents adapts that cursor to iter.Seq2[AuditEntry, error]. Its callback yields either a row or one
error. It returns when yield says false, and its deferred Close runs on exit. Go’s
range-over-function syntax requires Go 1.23 or later. Read the Go team’s explanation.
This sequence function creates a fresh cursor each time it is invoked. That is our API choice; a sequence may instead be single-use. The explicit cursor remains single-use. Neither the sample source statistics nor the cursor is designed for concurrent Next calls.
03 / Follow the work
One Next does not always mean one fetch.
With two rows per page, predict the first three pulls. The first fetches page 1 and delivers evt-1. The second delivers evt-2 from that buffer. The third fetches page 2 and delivers evt-3. Stop there and inspect the unread row that was released.
Reset and read all five entries. Notice that the session is still open just after the final entry is yielded. Ask for Next once more to observe exhaustion and close it. Then try an empty middle page and a failed second fetch: both change what work happens behind one Next, but their outcomes mean different things.
Pull one entry. Watch the page boundary.
Predict whether the next entry needs another page. Pull three entries, then stop and compare fetched rows with delivered rows.
Changing a choice starts a fresh traversal. Empty layouts have fixed page boundaries. A failure beyond the selected layout is cleared.
- Page requests
- 0
- Rows fetched
- 0
- Rows delivered
- 0
- Unread released
- 0
01 / Source pages
Fetch when the buffer runs out
evt-1 · evt-2
Continues at page 2evt-3 · evt-4
Continues at page 3evt-5
No continuation02 / Iterator buffer
Next yields one entry
No unread buffered entries.
Next page to request: 1. Any buffered entries come first.
03 / Consumer
Keep only what was delivered
No entries consumed yet.
Nothing is open or fetched. Predict what the first Next will do.
Inspect the source calls
No source calls yet.
| Outcome | Meaning | Later Next |
|---|---|---|
| Exhausted | The buffer is drained and no continuation remains. | Returns done, with no more source work. |
| Stopped early | The consumer explicitly ended this traversal. | Returns done; it cannot resume. |
| Failed | A page fetch failed; already delivered rows are partial. | Returns done, while the recorded failure remains. |
All terminal paths release the cursor’s buffer and close an opened session once. Stop before the first pull opens nothing. A failure is reported once when it happens; ignoring that error and observing done later does not make the traversal complete.
04 / Try a decision
Exhausted and broken must remain different answers.
Keeping pagination out of the consumer is useful only if the iterator preserves the information that consumer needs to judge its result.
Reason through the alternatives
Return done as soon as a fetched page contains no entries. Page 2 is empty, but its continuation says page 3 exists. This rule truncates the log before the caller reaches the remaining entries. Empty describes this page; exhausted describes the traversal.
Follow the continuation until an entry or the end is found; surface a fetch failure separately. The iterator can cross an empty page while satisfying one Next call. It stops successfully only after the continuation is absent and the buffer is drained. If page 3 fails, the caller receives an error and can identify the entries already consumed as a partial result.
Follow the continuation, but convert any fetch error to done so the consumer loop stays simple. The consumer now sees the same outcome for a complete log and a broken fetch. It could label an incomplete export as successful. Keeping the loop small is useful only if the result still tells the truth.
When does it fetch? When can it return from the current buffer? Who closes the session if the consumer has enough entries? What would make a partial result safe to display?
This note is local to the page. It is not saved or automatically assessed.05 / Give it a real job
An export job owns the traversal’s lifetime.
An export handler authorizes the user, fixes the query and ordering, creates a source adapter, and consumes entries. The iterator handles pagination. The handler decides whether to stop after a limit, write each entry to an output stream, or fail an incomplete export. It arranges cleanup around the entire consumption scope.
A real remote adapter should use asynchronous operations. In TypeScript that can mean an async iterator consumed with for-await-of.
Production continuation handling needs an explicit contract: stable ordering, the meaning of the cursor, what happens when records change between pages, and limits on empty or cycling responses. Cancellation and request deadlines must reach the source. A supposedly bounded consumer can still wait too long if one Next crosses an unbounded run of empty pages.
Retries need the same care. Recreating a cursor can read earlier entries again, while the consumer may already have written them somewhere. A resume token, stable snapshot, idempotent sink, or explicit partial-result policy may be needed.
Build UIs?The copy you take before removing elements in a loop is this lesson, and so is ending a review list’s traversal when its filter changes.
Where it already is in your components
You probably already follow this rule in code that touches DOM your framework does not
render, such as HTML from a CMS or a third-party widget: take a copy before removing
elements in a loop. Skip the copy and for (const chip of bar.children) chip.remove() clears only half the chips. In Chromium,
a bar of six kept the second, fourth, and sixth. That loop is this lesson’s iterator, run by
the browser over a collection that changes under it.
children and getElementsByClassName return an HTMLCollection, and the DOM standard makes it
live: its attributes and methods “operate on the actual underlying data, not a snapshot of
the data.” A for-of loop over it gets the array iterator, which Web IDL assigns to every interface with an indexed getter, and that iterator’s position is only an index.
Remove the chip at index 0 and the next chip moves into index 0 while the iterator moves on
to index 1. Removing a class while looping over getElementsByClassName skips elements the same way, because each one leaves
the collection when it stops matching. querySelectorAll returns a static list
instead, and MDN suggests iterating over a copy made with Array.from “if adding, moving, or removing
nodes.” What a traversal promises when its records change while it runs is the same question
section 05 leaves to a production contract.
The Load more list you already wrote runs the protocol by hand. Each click pulls one page
from an async generator, and nothing is fetched before the first click. The generator
stays outside state and yields whole pages rather than single reviews, because the button
needs each page’s next to know when to go away. What the pages produce goes into
state as an array. React walks an iterable child while it renders, so a generator kept across
renders lists its items once: in React 19.1.0 the next render showed an empty list and logged
“Using Iterators as children is unsupported and will likely yield unexpected results because
enumerating a generator mutates it.”
When you have to own it
Now the list gets a star-rating filter. Its URL changes with the rating, sometimes while a
page is still loading, and the list has to end the old traversal, as section 05’s export
job does. For an async generator, return() alone is not enough. While a next() waits on fetch, a return() waits in line behind it: in Chromium, a return() called during that wait settled only once the awaited promise had settled. So the owner passes
an AbortSignal in. On cleanup it aborts, the pending fetch rejects with AbortError, the generator ends, and return() answers done.
Two more decisions belong to the owner. A response can already be in hand when the filter changes, so each pull checks that its generator is still the current one before writing. Each list is stored with the URL it came from, so a new rating shows an empty list at once, without an effect copying state. A failed page throws, which ends the generator; the earlier reviews stay, with a message that the list is incomplete. Deciding which response is still current is UI race handling; ending the traversal you started is this lesson.
export type Review = { id: string; author: string; text: string };
export type ReviewPage = { reviews: Review[]; next: string | null };
// One page per next(). Creating the generator fetches nothing, and a caller that
// stops pulling stops the requests. Pages keep `next`, so a list knows when to
// hide its Load more button without asking for a page that does not exist.
export async function* reviewPages(
url: string,
signal?: AbortSignal
): AsyncGenerator<ReviewPage, void, undefined> {
let next: string | null = url;
while (next !== null) {
// return() cannot interrupt this await; aborting the signal can.
const response = await fetch(next, { signal });
if (!response.ok) throw new Error(`Reviews failed to load: HTTP ${response.status}`);
const page: ReviewPage = await response.json();
yield page;
next = page.next;
}
}
A product’s reviews with a Load more button. Each click pulls one page from an async generator, and the first click starts it.
import { useRef, useState } from 'react';
import { reviewPages, type Review, type ReviewPage } from './reviews';
export function LoadMoreReviews({ url }: { url: string }) {
// The traversal, in a ref: pulling a page is not a render. It is created on the
// first click, so a list nobody expands never fetches.
const pages = useRef<AsyncGenerator<ReviewPage, void> | null>(null);
// What the pages produced, as an array. React walks an iterable child as it
// renders, so a generator kept across renders would list its items only once.
const [reviews, setReviews] = useState<Review[]>([]);
const [status, setStatus] = useState<'more' | 'loading' | 'done' | 'failed'>('more');
async function loadMore() {
pages.current ??= reviewPages(url);
setStatus('loading');
try {
// One pull, one request.
const step = await pages.current.next();
if (step.done) return setStatus('done');
setReviews((shown) => [...shown, ...step.value.reviews]);
setStatus(step.value.next === null ? 'done' : 'more');
} catch {
setStatus('failed'); // A thrown page ends the generator; earlier reviews stay.
}
}
return (
<>
<ul>
{reviews.map((review) => (
// Keyed by the review's own id, not its position.
<li key={review.id}>{`${review.author}: ${review.text}`}</li>
))}
</ul>
{/* Loading, done, and failed are different states, not one flag. */}
{status === 'done' && <p>All reviews loaded.</p>}
{status === 'failed' && <p>Some reviews failed to load, so this list is incomplete.</p>}
{(status === 'more' || status === 'loading') && (
<button onClick={loadMore} disabled={status === 'loading'}>
Load more
</button>
)}
</>
);
}
06 / Already in your toolbox
The protocol often arrives with the language or library.
These public APIs expose the same questions about progress, failure, and lifetime.
Go · database/sql Rows
Rows.Next returns false both when there is no next row and when preparing a row fails. Rows.Err distinguishes those outcomes, and Rows.Close manages the result’s lifetime. This is a familiar example of why a boolean “next” result cannot by itself certify success.
Read Next, Err, and Close ↗Go · iter.Pull and stop
The iter package can adapt a sequence to next and stop functions. Its contract tells callers to stop when they no longer want values before exhaustion. That makes early termination part of the interface, even when a consumer drives iteration manually.
Read the pull iterator contract ↗JavaScript · generator objects
A generator function creates an object with its own suspended execution context. That object advances through next; another call to the function creates a separate traversal. A generator object is useful progress state, rather than a cached array that can automatically be read again.
Read how generators resume ↗07 / When it earns its place
Expose traversal when the caller should not reconstruct it.
Iterator helps when the source has a meaningful traversal protocol: pages, a tree walk, a stream of records, or a computed sequence. A common interface lets consumers process values while the traversal owns its progress. Generators can express that progress as ordinary control flow with suspension points.
Keep a normal collection when the caller needs indexing, repeated passes, sorting, or the whole result in memory. Using the language’s ordinary iteration over a small array is also fine. Introducing a custom resource-owning cursor is useful only when there is a corresponding lifetime or traversal responsibility.
Laziness changes when work happens. Constructing this cursor is cheap in source calls; advancing it may fetch a page or fail. Materializing its entries into an array still requests the entire sequence and retains the delivered values. A lazy source cannot prevent an eager consumer from collecting everything.
There is a page-size tradeoff: larger pages can reduce request count when reading far enough, while fetching more unused rows when the caller stops early. The lab shows those counts; a production page size also depends on latency and memory.
08 / Take the idea with you
Say who asks, who advances, and who releases.
Explain the design without its name: “The caller asks for another entry. A traversal object remembers its buffer and continuation, fetching only when it needs more data. If the caller stops or fetching fails, the traversal releases its session and does not silently restart.” Then explain why three entries can require two page fetches.
Connections to follow nextRelated lessons
Composite gives leaves and groups a shared operation boundary. An iterator can separately choose an order for visiting that tree. Adapter can translate a particular pagination API into the source contract used by a cursor.
Lazy initialization defers creating a retained value. This iterator defers portions of a traversal, keeping a changing position. Both require clear ownership, but their reuse rules differ.
Observer and publish/subscribe notify consumers when a producer emits. Our consumer initiates each pull. That limits this cursor’s fetching, but does not establish backpressure for an independently producing system; backpressure and queues explores that broader problem.