Iteration and symbols Core
Iterables and iterators
Learn the protocol behind for...of, spread and destructuring, then write your own iterables: a number range and an API paginator that fetches pages lazily.
BY THE END OF THIS LESSON YOU CAN
- Explain the iterable and iterator protocols and drive an iterator by hand with next()
- Predict what for...of, spread, destructuring and Array.from do with any iterable, including when they stop early
- Write reusable iterables such as a range, and tell a reusable iterable from a one-shot iterator
- Clean up resources with return() when a caller stops early
- Use iterator helpers to build lazy pipelines, and know when laziness helps
- Build and test a lazy paginator over a paged API
Ten thousand orders, one page at a time
A shop's orders API never sends every order at once. It sends a page: a few orders plus a cursor, a token that says where the next page starts. When there is no cursor, you have reached the end. Here is a small fake of such an API, with three orders per page, and the first code a team writes against it:
const ORDERS = [
{ id: "ORD-1", totalKobo: 450000 },
{ id: "ORD-2", totalKobo: 120000 },
{ id: "ORD-3", totalKobo: 980000 },
{ id: "ORD-4", totalKobo: 30000 },
{ id: "ORD-5", totalKobo: 2500000 },
{ id: "ORD-6", totalKobo: 75000 },
{ id: "ORD-7", totalKobo: 610000 },
];
let requests = 0;
function fetchOrdersPage(cursor = 0) {
requests += 1;
const items = ORDERS.slice(cursor, cursor + 3);
const next = cursor + 3 < ORDERS.length ? cursor + 3 : null;
return { items, nextCursor: next };
}
function loadAllOrders() {
const all = [];
let cursor = 0;
while (cursor !== null) {
const page = fetchOrdersPage(cursor);
all.push(...page.items);
cursor = page.nextCursor;
}
return all;
}
const firstBig = loadAllOrders().find((order) => order.totalKobo > 500000);
console.log(firstBig.id, "after", requests, "requests");
node problem.js and of the browser terminalORD-3 after 3 requests
The answer was on the first page, yet the code fetched all three. With seven orders that is harmless. With ten thousand orders it means thousands of requests and every order held in memory at once, just to find one. There is a second problem: every part of the program that needs orders repeats the same while (cursor !== null) loop, and each copy is a place for a paging bug.
What you want is to write this, and have pages fetched only when the loop actually needs them:
for (const order of orders) {
if (order.totalKobo > 500000) { found = order; break; }
}
for...of already works on arrays, strings, maps and sets. It is not special-cased for those types: it talks to them through a small agreement called the iteration protocol. Any object that follows the agreement works with for...of, spread, destructuring and many other parts of the language. This lesson teaches the agreement, and at the end you build an orders object that follows it and fetches lazily.
The protocol: iterables and iterators
Two roles take part, and it is worth keeping the words apart from the start:
- An iterator is an object with a
next()method. Each call tonext()returns a small result object{ value, done }. While there are items,doneisfalseandvalueis the item. When the items run out,doneistrue. - An iterable is an object that can give you an iterator. It has a method stored under a special key,
Symbol.iterator. Calling that method returns a fresh iterator.
Symbol.iterator is a well-known symbol: a unique value built into the language, used as a property key so it can never clash with a normal property name like "next" or "items". You met symbols briefly in Types in depth; Symbols covers them fully. For now, read obj[Symbol.iterator] as "the method that starts iterating this object".
You can do by hand what for...of does for you. Ask an array for its iterator, then call next() until done is true:
const cart = ["rice", "beans", "oil"];
const iterator = cart[Symbol.iterator]();
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
node by-hand.js and of the browser terminal{ value: 'rice', done: false }
{ value: 'beans', done: false }
{ value: 'oil', done: false }
{ value: undefined, done: true }
{ value: undefined, done: true }Three things to notice. The iterator keeps its own position: each next() moves one step. When it is finished, it stays finished: calling next() again keeps returning done: true. And the array itself did not change; the position lives in the iterator, not in the array.
iterable (the cart array) iterator (one walk over it)
┌──────────────────────────┐ calls ┌──────────────────────────┐
│ [Symbol.iterator]() ────┼──────────► │ position: 0 │
└──────────────────────────┘ returns │ next() → { value, done } │
└──────────────────────────┘
Ask the iterable for a new iterator every time you want to start from the beginning.
Why two objects instead of one?
Because you often need several walks over the same data at the same time. Comparing every order with every other order needs two positions over the same list. If the list itself stored "where am I", the two walks would fight over it. With separate iterators, each walk has its own position:
const sizes = ["S", "M", "L"];
for (const shirt of sizes) {
const pairs = [];
for (const trousers of sizes) pairs.push(shirt + trousers);
console.log(pairs.join(" "));
}
node two-walks.js and of the browser terminalSS SM SL MS MM ML LS LM LL
Each for...of asked the array for its own iterator, so the inner loop starting again did not disturb the outer one.
for...of under the hood
You met for...of in Loops. Here is what it really does, written out with a while loop. This version behaves the same as for (const item of cart) console.log(item):
const cart = ["rice", "beans", "oil"];
const iterator = cart[Symbol.iterator]();
while (true) {
const result = iterator.next();
if (result.done) break;
const item = result.value;
console.log(item);
}
node desugar.js and of the browser terminalrice beans oil
That is the whole mechanism: get an iterator once, call next() until done, and give each value to the loop body. There is one more step, for when the loop ends early, which you will see in Stopping early: return().
It also explains the error you get when you give for...of something that is not iterable. A plain object has no Symbol.iterator method, so the very first step fails:
const order = { id: "ORD-1", totalKobo: 450000 };
console.log(typeof order[Symbol.iterator]);
try {
for (const part of order) console.log(part);
} catch (error) {
console.log(`${error.name}: ${error.message}`);
}
for (const [key, value] of Object.entries(order)) {
console.log(key, value);
}
node not-iterable.js and of the browser terminalundefined TypeError: order is not iterable id ORD-1 totalKobo 450000
Plain objects are deliberately not iterable: it is not obvious whether you want their keys, their values or both. Object.entries answers that question and gives you an array, which is iterable.
for...of is not for...in
for...in is an older loop that walks an object's property names (as strings), including inherited enumerable ones. It knows nothing about the iteration protocol. On an array it gives you indexes as strings, and any extra property someone attached:
const prices = [500, 700];
prices.currency = "NGN";
for (const key in prices) console.log("in:", key, typeof key);
for (const price of prices) console.log("of:", price);
node for-in.js and of the browser terminalin: 0 string in: 1 string in: currency string of: 500 of: 700
Use for...of for the items of anything iterable. Use Object.keys/Object.entries with for...of when you mean the properties of a plain object.
Who else speaks the protocol
Once an object is iterable, many parts of the language accept it, because they all use Symbol.iterator and next() internally. These are called consumers of the protocol:
| Consumer | Example | Reads |
|---|---|---|
for...of | for (const x of it) | until done, or until break |
| Spread | [...it], Math.max(...it) | everything |
| Array destructuring | const [first, second] = it | only as many as it needs |
Array.from | Array.from(it, fn) | everything |
| Collections | new Set(it), new Map(pairs) | everything |
| Promise combinators | Promise.all(it) | everything |
Object.fromEntries | Object.fromEntries(pairs) | everything |
yield* | inside a generator | everything (see Generators) |
To see the protocol being used, wrap an array's iterator in one that reports each call. This is a first custom iterable: an object whose [Symbol.iterator] method returns an object with next(). Square brackets around the method name make it a computed key, so the method's key is the symbol itself:
function spy(label, items) {
return {
[Symbol.iterator]() {
const inner = items[Symbol.iterator]();
return {
next() {
const result = inner.next();
console.log(` ${label}: next() -> ${result.done ? "done" : result.value}`);
return result;
},
};
},
};
}
const skus = ["RICE-5", "OIL-1", "SALT"];
console.log("spread:");
const copy = [...spy("spread", skus)];
console.log("destructuring two:");
const [first, second] = spy("destructure", skus);
console.log(first, second, copy.length);
node spy.js and of the browser terminalspread: spread: next() -> RICE-5 spread: next() -> OIL-1 spread: next() -> SALT spread: next() -> done destructuring two: destructure: next() -> RICE-5 destructure: next() -> OIL-1 RICE-5 OIL-1 3
Spread reads until done. Destructuring two names calls next() exactly twice and never asks for the third item. That difference matters as soon as producing an item costs something, such as a network request: destructuring the first order from a paginator should fetch one page, not all of them.
Strings iterate by code point
Built-in iterables decide for themselves what an "item" is. A string's iterator gives you code points (whole Unicode characters), not the 16-bit units that .length and [index] count. An emoji such as a shopping bag takes two of those units:
const label = "₦5🛍";
console.log(label.length);
console.log([...label]);
console.log([...label].length);
console.log(label[2] === "🛍");
node string-iter.js and of the browser terminal4 [ '₦', '5', '🛍' ] 3 false
So [...text].length counts characters more honestly than text.length. It still counts some things a person sees as one symbol (flags, skin-tone emoji) as several; Strings in depth covers that last step with Intl.Segmenter.
Maps and Sets hand out several iterators
A Map is iterable (its default items are [key, value] pairs), and it also has keys(), values() and entries(), which each return an iterator. Arrays have the same three methods. These return iterators, not arrays, which leads to the most common iterator surprise.
Reusable iterables and one-shot iterators
Built-in iterators are themselves iterable: their [Symbol.iterator]() method returns the same iterator, this. That is what lets you write for (const price of stock.values()). But an iterator can only be walked once, because it keeps its position:
const stock = new Map([
["RICE-5", 12],
["OIL-1", 0],
["SALT", 40],
]);
const counts = stock.values();
console.log(counts[Symbol.iterator]() === counts);
const total = [...counts].reduce((sum, n) => sum + n, 0);
const highest = Math.max(...counts);
console.log(total, highest);
console.log(Math.max(...stock.values()));
node one-shot.js and of the browser terminaltrue 52 -Infinity 40
The first spread used up counts. The second spread got an iterator that was already finished, so Math.max() received no arguments at all, and the maximum of nothing is -Infinity. No error, just a wrong number. Asking the map again, stock.values(), gives a fresh iterator and the correct answer.
The rule to remember:
- An iterable such as an array, a map or a set can be walked many times. Each walk asks for a new iterator.
- An iterator is one walk. Store it in a variable only when you mean to share that one walk between several readers.
When a function receives "something iterable" and needs to read it twice, copy it into an array first: const list = [...input]. When you write an iterable, return a fresh iterator from every [Symbol.iterator]() call, so your object behaves like an array and not like a used-up iterator.
Writing your own: a range
JavaScript has no built-in way to say "the numbers from 1 to 5". Receipts, invoice numbers, days of a booking and seat numbers all need one. A range is the classic first custom iterable, because it shows the key idea: the numbers never exist in memory as a list. Each one is computed when next() asks for it.
export function range(start, end, step = 1) {
if (!Number.isFinite(start) || !Number.isFinite(end)) throw new TypeError("start and end must be numbers");
if (step === 0 || !Number.isFinite(step)) throw new RangeError("step must be a non-zero number");
return {
[Symbol.iterator]() {
let current = start;
return {
next() {
const inside = step > 0 ? current < end : current > end;
if (!inside) return { value: undefined, done: true };
const value = current;
current += step;
return { value, done: false };
},
};
},
};
}
import { range } from "./range.js";
console.log([...range(1, 6)]);
console.log([...range(0, 50, 10)]);
console.log([...range(5, 0, -1)]);
console.log([...range(3, 3)]);
const nights = range(12, 15);
for (const day of nights) console.log(`Room 7 booked for Oct ${day}`);
console.log("walk again:", [...nights].join(","));
try {
range(1, 10, 0);
} catch (error) {
console.log(`${error.name}: ${error.message}`);
}
node use-range.js and of the browser terminal[ 1, 2, 3, 4, 5 ] [ 0, 10, 20, 30, 40 ] [ 5, 4, 3, 2, 1 ] [] Room 7 booked for Oct 12 Room 7 booked for Oct 13 Room 7 booked for Oct 14 walk again: 12,13,14 RangeError: step must be a non-zero number
The design decisions in those few lines:
- The end is excluded, like
slice(start, end).range(12, 15)gives three nights, andend - startis the count. Pick one convention and keep it everywhere. - The position lives inside
[Symbol.iterator](), in a closure (Closures in depth). Every walk gets its owncurrent, which is why walkingnightsa second time works. - A step of 0 is refused at creation. It would make
currentnever move and every loop over the range infinite. Checking inrange(), not innext(), reports the mistake where it was made. - Negative steps count down, so the "still inside?" test flips direction.
The last lines show the validation working: a step of 0 is refused with a RangeError the moment the range is created, before anyone tries to loop over it.
Stopping early: return()
Some iterators hold something that must be released: an open database cursor, a file handle, a lock on a page of results. If the caller reads to the end, the iterator knows it is finished and can release it when it returns done: true. But the caller may stop early: a break, a return from the surrounding function, an exception in the loop body, or destructuring that only needs two items.
For that case the protocol has an optional second method, return(). When a consumer stops before done, it calls iterator.return() if the method exists. That is the step missing from the while version earlier. Here a fake database cursor logs when it opens and closes:
function openCursor(rows) {
return {
[Symbol.iterator]() {
console.log(" cursor opened");
let index = 0;
let open = true;
const close = () => {
if (open) console.log(" cursor closed");
open = false;
};
return {
next() {
if (index >= rows.length) {
close();
return { value: undefined, done: true };
}
return { value: rows[index++], done: false };
},
return() {
close();
return { value: undefined, done: true };
},
};
},
};
}
const rows = ["ORD-1", "ORD-2", "ORD-3"];
console.log("read all:");
for (const id of openCursor(rows)) console.log(" ", id);
console.log("break:");
for (const id of openCursor(rows)) {
console.log(" ", id);
if (id === "ORD-2") break;
}
console.log("throw:");
try {
for (const id of openCursor(rows)) throw new Error(`cannot ship ${id}`);
} catch (error) {
console.log(" ", error.message);
}
console.log("destructure one:");
const [firstId] = openCursor(rows);
console.log(" ", firstId);
node return.js and of the browser terminalread all: cursor opened ORD-1 ORD-2 ORD-3 cursor closed break: cursor opened ORD-1 ORD-2 cursor closed throw: cursor opened cursor closed cannot ship ORD-1 destructure one: cursor opened cursor closed ORD-1
In every case the cursor closed exactly once. Notice the order in the "throw" case: the cursor was closed before the catch block ran, because for...of calls return() on its way out, the same way a finally block would run. The open flag makes close safe to call twice, which is a good habit for any cleanup.
WATCH OUT
Callingnext() yourself, in a while loop, gives you none of this. If you stop early, calling return() is your job. Prefer for...of and destructuring, which do it for you.Laziness and infinite sequences
A range computes each number when asked. That property is called laziness: no work happens until someone calls next(). It has a surprising consequence: an iterable can be infinite, as long as nobody tries to read all of it.
An invoice number generator is a natural example. Invoices never "run out"; you just take the next one:
function invoiceNumbers(prefix, start = 1) {
return {
[Symbol.iterator]() {
let n = start;
return {
next: () => ({ value: `${prefix}-${String(n++).padStart(5, "0")}`, done: false }),
};
},
};
}
const numbers = invoiceNumbers("INV-2026", 98);
const orders = ["ORD-1", "ORD-2", "ORD-3"];
const iterator = numbers[Symbol.iterator]();
for (const order of orders) {
console.log(order, "->", iterator.next().value);
}
node invoice-numbers.js and of the browser terminalORD-1 -> INV-2026-00098 ORD-2 -> INV-2026-00099 ORD-3 -> INV-2026-00100
This iterator never returns done: true. Walking it with a for...of without a break, or spreading it with [...numbers], would run forever and then crash when memory runs out. That is the price of infinite sequences: the consumer must decide when to stop. The next section shows a clean way to do that.
Iterator helpers: map, filter and take, lazily
Arrays have map, filter and friends, but they only work on arrays, and each one builds a whole new array before the next starts. Since ES2025, iterators have their own versions, the iterator helpers, on Iterator.prototype: map, filter, take, drop, flatMap, plus finishing methods toArray, reduce, forEach, some, every and find. They exist in Node.js 22 and later and in current Chrome, Firefox and Safari.
The difference is not the syntax, it is the order of the work. Watch both versions find the first two large orders:
const totals = [450000, 120000, 980000, 30000, 2500000, 75000];
console.log("array methods (eager):");
const eager = totals
.map((kobo) => { console.log(" map", kobo); return kobo / 100; })
.filter((naira) => naira > 4000)
.slice(0, 2);
console.log(eager);
console.log("iterator helpers (lazy):");
const lazy = totals
.values()
.map((kobo) => { console.log(" map", kobo); return kobo / 100; })
.filter((naira) => naira > 4000)
.take(2)
.toArray();
console.log(lazy);
node lazy-vs-eager.js and of the browser terminalarray methods (eager): map 450000 map 120000 map 980000 map 30000 map 2500000 map 75000 [ 4500, 9800 ] iterator helpers (lazy): map 450000 map 120000 map 980000 [ 4500, 9800 ]
The array version mapped all six totals, built an array, filtered it into another array, then threw most of it away. The iterator version pulls one item at a time through the whole chain: toArray asks take, which asks filter, which asks map, which asks the array's iterator. As soon as take(2) has two items it stops asking, so the last three totals were never touched. totals.values() is the step that turns the array into an iterator.
Laziness is what makes infinite sequences usable. take is the "decide when to stop" from the previous section:
function naturals() {
let n = 1;
return Iterator.from({ next: () => ({ value: n++, done: false }) });
}
const firstSeats = naturals()
.filter((seat) => seat % 13 !== 0)
.map((seat) => `Seat ${seat}`)
.drop(10)
.take(4)
.toArray();
console.log(firstSeats);
const firstOver = naturals().find((n) => n * n > 2000);
console.log(firstOver);
node helpers-infinite.js and of the browser terminal[ 'Seat 11', 'Seat 12', 'Seat 14', 'Seat 15' ] 45
The seat numbering skips unlucky multiples of 13, drops the first ten seats (reserved for staff), and takes four. find stops at the first match, which is why searching an infinite sequence ends.
Iterator.from: giving your iterators the helpers
The helpers live on Iterator.prototype. Built-in iterators inherit from it (the prototype chain from Prototypes in depth). An iterator you write as a plain object literal does not, so it has no map. Iterator.from(x) fixes that: given an iterable, it returns the iterable's iterator; given a bare iterator object with next(), it wraps it in one that inherits the helpers:
const plain = {
items: ["RICE-5", "OIL-1", "SALT"],
[Symbol.iterator]() {
let i = 0;
return { next: () => (i < this.items.length ? { value: this.items[i++], done: false } : { value: undefined, done: true }) };
},
};
const raw = plain[Symbol.iterator]();
console.log(typeof raw.map);
const wrapped = Iterator.from(plain);
console.log(typeof wrapped.map);
console.log(wrapped.map((sku) => sku.toLowerCase()).toArray());
node iterator-from.js and of the browser terminalundefined function [ 'rice-5', 'oil-1', 'salt' ]
When to use which
- Data already in a small array that you need several times: array methods. They are familiar, and the result is a reusable array.
- Large, expensive, streaming or infinite sources, or when you will stop early: iterator helpers. They do only the work that is needed and hold one item at a time.
- Remember that a helper chain is an iterator: one-shot. Call
toArray()at the end if you need to keep the results.
NOTE
The proposal also addsIterator.concat (join several iterables). At the time of writing, Chrome has it but Node.js 24 does not, so this course does not rely on it. Check an API in every runtime you deploy to before you depend on it.When iteration goes wrong
Most iteration bugs come from forgetting that an iterator is live: it reads the collection as it goes, rather than taking a snapshot at the start.
Changing an array while you walk it
An array iterator checks the array's current length on every next(). Adding items inside the loop means the loop will also visit them. A "buy one, get one free" rule that pushes the free item into the same cart would never end; here a guard stops it after a few turns:
const cart = ["shirt", "shoes"];
let turns = 0;
for (const item of cart) {
turns += 1;
if (turns > 5) {
console.log("stopped: the loop kept finding new items");
break;
}
cart.push(`free ${item}`);
}
console.log(cart.length, cart.slice(0, 4));
const safeCart = ["shirt", "shoes"];
for (const item of [...safeCart]) safeCart.push(`free ${item}`);
console.log(safeCart);
node mutate-array.js and of the browser terminalstopped: the loop kept finding new items 7 [ 'shirt', 'shoes', 'free shirt', 'free shoes' ] [ 'shirt', 'shoes', 'free shirt', 'free shoes' ]
Walking a copy ([...safeCart]) freezes the list of items to visit, so changes to the original do not affect the loop. Even better, do not change a collection while walking it: build a new array of results instead.
Deleting from a Map or Set while walking it
Map and Set define this case precisely: deleting an entry that has not been visited yet means it is skipped; entries added during the walk are visited. Deleting the current entry is safe, which makes "remove expired items" loops simple:
const sessions = new Map([
["s1", { user: "ada", expiresAt: 100 }],
["s2", { user: "chidi", expiresAt: 900 }],
["s3", { user: "tunde", expiresAt: 50 }],
]);
const now = 500;
for (const [id, session] of sessions) {
if (session.expiresAt < now) sessions.delete(id);
}
console.log([...sessions.keys()]);
node map-delete.js and of the browser terminal[ 's2' ]
A summary of the traps
| Symptom | Cause | Fix |
|---|---|---|
Second loop over a value does nothing, or Math.max gives -Infinity | It was an iterator, already used up | Ask the iterable again, or copy to an array once |
| Your iterable works once, then yields nothing | [Symbol.iterator]() returns the same iterator every time | Create the position inside [Symbol.iterator]() |
| Loop never ends | Infinite iterable spread or looped without a stop; items added during the loop; step of 0 | take/break; iterate a copy; validate arguments |
x is not iterable | A plain object, undefined or null given to for...of or spread | Use Object.entries; default missing lists to [] |
Resources left open after break | No return(), or next() called by hand | Implement return(); consume with for...of |
Before you build: the orders paginator
REASON IT OUT
Design a lazy paginator
You will now turn the paging loop from the first section into an iterable, so that any code can write for (const order of orders). Before reading the code, think through:
- When should the first request happen: when the paginator is created, or when someone starts iterating?
- A caller finds what it needs on page 1 and
breaks. How many pages should have been fetched? - What if the API returns an empty page with a cursor? What if the very first page is empty with no cursor?
- Two parts of the program iterate the same
ordersobject. Should they share one position, or each start from the first page? - What should happen if the API returns the same cursor it was given?
Show the reasoning
- When to fetch: only inside
next(), and only when the current page's items are used up. Creating the paginator costs nothing; a paginator that nobody reads never makes a request. - Early stop: exactly one page.
next()fetches a page only when the buffer is empty and there is a cursor to follow, so abreakafter an item from page 1 never triggers page 2. - Empty pages: an empty page with a cursor is legal for some APIs (for example, when every item on it was filtered out on the server). The paginator must keep fetching, in a loop, until it has an item or the cursor is
null. An empty first page with no cursor means "no orders", and the loop must simply end. - Several walks: each
for...ofshould start again from page 1, like an array. So the cursor and the buffer live inside[Symbol.iterator](), not on the paginator object. - Repeated cursor: that is a server bug that would make the loop spin forever, fetching the same page. The paginator should refuse to follow a cursor it has already seen, and throw a clear error.
Build: a lazy paginator
The paginator takes the function that fetches one page, so it works for any API with the { items, nextCursor } shape. That also makes it easy to test, because a test can pass a fake.
export function paginate(fetchPage, { firstCursor = null } = {}) {
return {
[Symbol.iterator]() {
let buffer = [];
let cursor = firstCursor;
let started = false;
let finished = false;
const seen = new Set();
function loadNextPage() {
if (started && cursor === null) {
finished = true;
return;
}
if (cursor !== null && seen.has(cursor)) throw new Error(`cursor repeated: ${cursor}`);
if (cursor !== null) seen.add(cursor);
started = true;
const page = fetchPage(cursor);
buffer = [...page.items];
cursor = page.nextCursor ?? null;
}
return {
next() {
while (buffer.length === 0 && !finished) loadNextPage();
if (buffer.length === 0) return { value: undefined, done: true };
return { value: buffer.shift(), done: false };
},
return() {
buffer = [];
finished = true;
return { value: undefined, done: true };
},
};
},
};
}
How it works:
bufferholds the unread items of the current page.next()hands them out one by one withshift()(a page is small, so removing from the front is cheap here).- When the buffer is empty,
next()loads pages in awhileloop until it has an item or there are no more pages. That loop is what handles empty pages in the middle. startedseparates "the first request, with no cursor yet" from "the server said there is no next page". Both havecursor === null.return()marks the walk finished, so an early stop never triggers another request.
Now the fake API from the start, with a counter, and the loop you wanted to write:
export const ORDERS = [
{ id: "ORD-1", totalKobo: 450000 },
{ id: "ORD-2", totalKobo: 120000 },
{ id: "ORD-3", totalKobo: 980000 },
{ id: "ORD-4", totalKobo: 30000 },
{ id: "ORD-5", totalKobo: 2500000 },
{ id: "ORD-6", totalKobo: 75000 },
{ id: "ORD-7", totalKobo: 610000 },
];
export function createFakeApi(orders, pageSize = 3) {
const api = {
requests: 0,
fetchOrdersPage(cursor) {
api.requests += 1;
const start = cursor === null ? 0 : Number(cursor);
const next = start + pageSize < orders.length ? String(start + pageSize) : null;
return { items: orders.slice(start, start + pageSize), nextCursor: next };
},
};
return api;
}
import { paginate } from "./paginate.js";
import { ORDERS, createFakeApi } from "./orders-api.js";
const api = createFakeApi(ORDERS);
const orders = paginate(api.fetchOrdersPage);
console.log("requests after creating:", api.requests);
let firstBig = null;
for (const order of orders) {
if (order.totalKobo > 500000) {
firstBig = order;
break;
}
}
console.log(firstBig.id, "after", api.requests, "request");
const [first, second] = orders;
console.log(first.id, second.id, "after", api.requests, "requests");
const allIds = Iterator.from(orders).map((order) => order.id).toArray();
console.log(allIds.join(" "), "after", api.requests, "requests");
node main.js and of the browser terminalrequests after creating: 0 ORD-3 after 1 request ORD-1 ORD-2 after 2 requests ORD-1 ORD-2 ORD-3 ORD-4 ORD-5 ORD-6 ORD-7 after 5 requests
Creating the paginator made no request. Finding the first big order cost one request instead of three. Destructuring two orders started a new walk from page 1 and fetched one more page. Reading everything took three pages. And orders works with for...of, destructuring and iterator helpers, because it follows the protocol.
Testing the paginator
The edge cases from the reasoning step each deserve a test. A fake fetchPage built from a list of pages lets each test describe exactly what the server returns:
import { paginate } from "./paginate.js";
function check(label, actual, expected) {
const same = JSON.stringify(actual) === JSON.stringify(expected);
console.log(`${same ? "PASS" : "FAIL"} ${label} -> ${JSON.stringify(actual)}`);
}
function fakePages(pages) {
const calls = [];
const fetchPage = (cursor) => {
calls.push(cursor);
return pages[cursor ?? "start"];
};
return { calls, fetchPage };
}
{
const { calls, fetchPage } = fakePages({ start: { items: [], nextCursor: null } });
check("no orders at all", [...paginate(fetchPage)], []);
check("one request for an empty result", calls.length, 1);
}
{
const { fetchPage } = fakePages({
start: { items: ["A"], nextCursor: "p2" },
p2: { items: [], nextCursor: "p3" },
p3: { items: ["B"], nextCursor: null },
});
check("skips an empty page in the middle", [...paginate(fetchPage)], ["A", "B"]);
}
{
const { calls, fetchPage } = fakePages({
start: { items: ["A", "B"], nextCursor: "p2" },
p2: { items: ["C"], nextCursor: null },
});
const [first] = paginate(fetchPage);
check("destructuring one item fetches one page", [first, calls], ["A", [null]]);
}
{
const { calls, fetchPage } = fakePages({
start: { items: ["A"], nextCursor: "p2" },
p2: { items: ["B"], nextCursor: null },
});
const orders = paginate(fetchPage);
check("walk 1", [...orders], ["A", "B"]);
check("walk 2 starts again", [...orders], ["A", "B"]);
check("requests for two walks", calls.length, 4);
}
{
const { fetchPage } = fakePages({
start: { items: ["A"], nextCursor: "p2" },
p2: { items: ["B"], nextCursor: "p2" },
});
let message = null;
try {
[...paginate(fetchPage)];
} catch (error) {
message = error.message;
}
check("refuses a repeated cursor", message, "cursor repeated: p2");
}
node paginate.test.js and of the browser terminalPASS no orders at all -> [] PASS one request for an empty result -> 1 PASS skips an empty page in the middle -> ["A","B"] PASS destructuring one item fetches one page -> ["A",[null]] PASS walk 1 -> ["A","B"] PASS walk 2 starts again -> ["A","B"] PASS requests for two walks -> 4 PASS refuses a repeated cursor -> "cursor repeated: p2"
Checking calls, not just the items, is what tests the laziness. A paginator that fetched every page up front would still return the right items, and only the request count would catch it.
Real APIs are asynchronous: Symbol.asyncIterator
A real fetch returns a promise (Asynchronous JavaScript), and a synchronous next() cannot wait for it. For that case there is a twin protocol: an async iterable has a [Symbol.asyncIterator]() method, its iterator's next() returns a promise of { value, done }, and you consume it with for await...of inside an async function or a module. The design stays the same:
export function paginateAsync(fetchPage) {
return {
[Symbol.asyncIterator]() {
let buffer = [];
let cursor = null;
let started = false;
return {
async next() {
while (buffer.length === 0 && !(started && cursor === null)) {
const page = await fetchPage(cursor);
started = true;
buffer = [...page.items];
cursor = page.nextCursor ?? null;
}
if (buffer.length === 0) return { value: undefined, done: true };
return { value: buffer.shift(), done: false };
},
};
},
};
}
import { paginateAsync } from "./paginate-async.js";
import { ORDERS, createFakeApi } from "./orders-api.js";
const api = createFakeApi(ORDERS);
const slowFetch = async (cursor) => {
await new Promise((resolve) => setTimeout(resolve, 5));
return api.fetchOrdersPage(cursor);
};
let totalKobo = 0;
for await (const order of paginateAsync(slowFetch)) {
totalKobo += order.totalKobo;
}
console.log(`₦${totalKobo / 100} from ${api.requests} requests`);
node main-async.js and of the browser terminal₦47650 from 3 requests
Writing next() by hand, with its buffer and flags, is a lot of bookkeeping for "loop over pages and hand out items". Generators, the next lesson, let you write the same paginator as an ordinary loop with yield, and JavaScript builds the iterator for you. Understanding the protocol first is what makes generators easy to reason about.
In production
- Cursor paging, not offset paging. Paging with
?offset=20&limit=10skips or repeats items when orders are inserted or deleted between requests. An opaque cursor from the server, as used here, does not. - Cap the walk. A paginator that follows cursors forever can run up a large bill or hit rate limits. Accept a
maxPagesoption, or let callers usetake. - Retries belong to one page. Wrap
fetchPagein a retry for temporary network errors, so a failure on page 40 does not restart the walk from page 1. - Memory stays flat. Only one page is held at a time, which is the whole reason to iterate instead of loading everything. Code that spreads the paginator into an array (
[...orders]) throws that benefit away; do that only when the result is known to be small.
Practice
TRY IT YOURSELF
Round-robin ticket assignment
Support tickets are assigned to agents in turn: Ada, Chidi, Tunde, Ada, Chidi, and so on, forever. Write cycle(items), an infinite iterable that repeats the items of an array in order. Use it to assign five tickets, and use take to list the first four turns. What should happen with an empty array?
Show a solution
function cycle(items) {
const list = [...items];
if (list.length === 0) throw new RangeError("cycle needs at least one item");
return {
[Symbol.iterator]() {
let i = 0;
return Iterator.from({
next: () => ({ value: list[i++ % list.length], done: false }),
});
},
};
}
const agents = cycle(["Ada", "Chidi", "Tunde"]);
const tickets = ["T-101", "T-102", "T-103", "T-104", "T-105"];
const who = agents[Symbol.iterator]();
for (const ticket of tickets) console.log(ticket, "->", who.next().value);
console.log(Iterator.from(agents).take(4).toArray());
try {
cycle([]);
} catch (error) {
console.log(`${error.name}: ${error.message}`);
}
node cycle.js and of the browser terminalT-101 -> Ada T-102 -> Chidi T-103 -> Tunde T-104 -> Ada T-105 -> Chidi [ 'Ada', 'Chidi', 'Tunde', 'Ada' ] RangeError: cycle needs at least one item
An empty array would make list[i % 0] read list[NaN], which is undefined, forever: an infinite stream of "nobody". Refusing it at creation is the same choice as refusing a step of 0 in range. Copying with [...items] makes the cycle independent of later changes to the caller's array.
TRY IT YOURSELF
Make a cart iterable
Write a Cart class that stores quantities in a Map from SKU to quantity, with add(sku, qty). Make it iterable so that for (const line of cart) gives objects { sku, qty }, and [...cart] works. Walking it twice must work.
Show a solution
class Cart {
#lines = new Map();
add(sku, qty = 1) {
this.#lines.set(sku, (this.#lines.get(sku) ?? 0) + qty);
return this;
}
[Symbol.iterator]() {
return this.#lines
.entries()
.map(([sku, qty]) => ({ sku, qty }));
}
}
const cart = new Cart().add("RICE-5", 2).add("OIL-1").add("RICE-5");
for (const line of cart) console.log(line);
console.log([...cart].length, [...cart][0].qty);
node cart-iterable.js and of the browser terminal{ sku: 'RICE-5', qty: 3 }
{ sku: 'OIL-1', qty: 1 }
2 3A class can define [Symbol.iterator]() as a method with a computed name. Here it borrows the Map's own iterator and reshapes each entry with the map helper. Because it calls entries() every time, every walk is a fresh one. The private #lines field keeps callers from changing quantities except through add.
TRY IT YOURSELF
Batch inserts with chunk
Saving 10,000 orders one row at a time is slow; databases prefer batches. Write chunk(iterable, size), a lazy iterable that yields arrays of up to size items from any iterable, including a paginator or an infinite sequence. The last batch may be shorter.
Show a solution
function chunk(iterable, size) {
if (!Number.isInteger(size) || size < 1) throw new RangeError("size must be a positive whole number");
return {
[Symbol.iterator]() {
const source = iterable[Symbol.iterator]();
let finished = false;
return {
next() {
if (finished) return { value: undefined, done: true };
const batch = [];
while (batch.length < size) {
const result = source.next();
if (result.done) {
finished = true;
break;
}
batch.push(result.value);
}
return batch.length ? { value: batch, done: false } : { value: undefined, done: true };
},
return() {
finished = true;
source.return?.();
return { value: undefined, done: true };
},
};
},
};
}
const orderIds = ["ORD-1", "ORD-2", "ORD-3", "ORD-4", "ORD-5", "ORD-6", "ORD-7"];
for (const batch of chunk(orderIds, 3)) console.log("INSERT", batch);
let n = 0;
const endless = { [Symbol.iterator]: () => ({ next: () => ({ value: ++n, done: false }) }) };
const [firstBatch, secondBatch] = chunk(endless, 2);
console.log(firstBatch, secondBatch);
node chunk.js and of the browser terminalINSERT [ 'ORD-1', 'ORD-2', 'ORD-3' ] INSERT [ 'ORD-4', 'ORD-5', 'ORD-6' ] INSERT [ 'ORD-7' ] [ 1, 2 ] [ 3, 4 ]
chunk pulls from the source only when a batch is asked for, so it works on the endless sequence. Its return() passes the early stop on to the source with source.return?.() (optional chaining, because return is optional), so a paginator or cursor underneath is closed too. Forwarding return() is the detail most hand-written wrappers forget.
Recap
- An iterator has
next(), which returns{ value, done }. An iterable has[Symbol.iterator](), which returns a new iterator. for...of, spread, array destructuring,Array.from,new Map/Set,Promise.allandObject.fromEntriesall consume iterables through that protocol. Destructuring reads only what it needs.- Iterables can be walked many times; iterators are one-shot. Create the position inside
[Symbol.iterator]()so your own iterables are reusable. - When a consumer stops early (
break,throw,return, destructuring), it calls the iterator's optionalreturn(). Put cleanup there. - Iterators are lazy, so they can be infinite. Iterator helpers (
map,filter,take,drop,toArray, …) build lazy pipelines;Iterator.fromgives them to your own iterators. - A paginator is an iterable that fetches a page only when its buffer is empty. Test the number of requests, not just the items. For promises, use
Symbol.asyncIteratorandfor await...of.
Next: Generators, which write iterators like these as ordinary loops with yield.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.