LEVEL 4 · LESSON 8 OF 17

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.

  • 50 min to read and try
  • You need: Loops, Arrays, Closures in depth and Prototypes in depth
  • You build: A lazy paginator that walks an orders API page by page, stops fetching when the caller stops reading, and is tested with a fake API
Test yourself

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:

problem.js
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");
Output of node problem.js and of the browser terminal
ORD-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 to next() returns a small result object { value, done }. While there are items, done is false and value is the item. When the items run out, done is true.
  • 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:

by-hand.js
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());
Output of 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.
The iterable starts walks; each iterator is one walk with its own position.

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:

two-walks.js
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(" "));
}
Output of node two-walks.js and of the browser terminal
SS 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):

desugar.js
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);
}
Output of node desugar.js and of the browser terminal
rice
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:

not-iterable.js
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);
}
Output of node not-iterable.js and of the browser terminal
undefined
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:

for-in.js
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);
Output of node for-in.js and of the browser terminal
in: 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:

ConsumerExampleReads
for...offor (const x of it)until done, or until break
Spread[...it], Math.max(...it)everything
Array destructuringconst [first, second] = itonly as many as it needs
Array.fromArray.from(it, fn)everything
Collectionsnew Set(it), new Map(pairs)everything
Promise combinatorsPromise.all(it)everything
Object.fromEntriesObject.fromEntries(pairs)everything
yield*inside a generatoreverything (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:

spy.js
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);
Output of node spy.js and of the browser terminal
spread:
  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:

string-iter.js
const label = "₦5🛍";

console.log(label.length);
console.log([...label]);
console.log([...label].length);
console.log(label[2] === "🛍");
Output of node string-iter.js and of the browser terminal
4
[ '₦', '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:

one-shot.js
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()));
Output of node one-shot.js and of the browser terminal
true
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.

range.js
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 };
        },
      };
    },
  };
}
use-range.js
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}`);
}
Output of 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, and end - start is 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 own current, which is why walking nights a second time works.
  • A step of 0 is refused at creation. It would make current never move and every loop over the range infinite. Checking in range(), not in next(), 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:

return.js
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);
Output of node return.js and of the browser terminal
read 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

Calling next() 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:

invoice-numbers.js
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);
}
Output of node invoice-numbers.js and of the browser terminal
ORD-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:

lazy-vs-eager.js
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);
Output of node lazy-vs-eager.js and of the browser terminal
array 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:

helpers-infinite.js
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);
Output of 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:

iterator-from.js
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());
Output of node iterator-from.js and of the browser terminal
undefined
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 adds Iterator.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:

mutate-array.js
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);
Output of node mutate-array.js and of the browser terminal
stopped: 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:

map-delete.js
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()]);
Output of node map-delete.js and of the browser terminal
[ 's2' ]

A summary of the traps

SymptomCauseFix
Second loop over a value does nothing, or Math.max gives -InfinityIt was an iterator, already used upAsk the iterable again, or copy to an array once
Your iterable works once, then yields nothing[Symbol.iterator]() returns the same iterator every timeCreate the position inside [Symbol.iterator]()
Loop never endsInfinite iterable spread or looped without a stop; items added during the loop; step of 0take/break; iterate a copy; validate arguments
x is not iterableA plain object, undefined or null given to for...of or spreadUse Object.entries; default missing lists to []
Resources left open after breakNo return(), or next() called by handImplement 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 orders object. 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 a break after 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...of should 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.

paginate.js
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:

  • buffer holds the unread items of the current page. next() hands them out one by one with shift() (a page is small, so removing from the front is cheap here).
  • When the buffer is empty, next() loads pages in a while loop until it has an item or there are no more pages. That loop is what handles empty pages in the middle.
  • started separates "the first request, with no cursor yet" from "the server said there is no next page". Both have cursor === 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:

orders-api.js
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;
}
main.js
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");
Output of node main.js and of the browser terminal
requests 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:

paginate.test.js
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");
}
Output of node paginate.test.js and of the browser terminal
PASS 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:

paginate-async.js
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 };
        },
      };
    },
  };
}
main-async.js
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`);
Output of 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=10 skips 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 maxPages option, or let callers use take.
  • Retries belong to one page. Wrap fetchPage in 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
cycle.js
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}`);
}
Output of node cycle.js and of the browser terminal
T-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
cart-iterable.js
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);
Output of node cart-iterable.js and of the browser terminal
{ sku: 'RICE-5', qty: 3 }
{ sku: 'OIL-1', qty: 1 }
2 3

A 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
chunk.js
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);
Output of node chunk.js and of the browser terminal
INSERT [ '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.all and Object.fromEntries all 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 optional return(). Put cleanup there.
  • Iterators are lazy, so they can be infinite. Iterator helpers (map, filter, take, drop, toArray, …) build lazy pipelines; Iterator.from gives 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.asyncIterator and for 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.