skip to content

A function loops through every page of a paginated HTTP API, pushing each page's items into one array, and returns that array once the last page arrives. How would you restructure it as an async generator, and what does that change for the caller?

level: middleimportance: should knowfreq 48%

answer

  1. yield instead of push
  2. first result before the last request
  3. memory bounded to one page
  4. early exit skips the remaining fetches
  5. caller loses the array interface

basics

~20 s

Declare it async function*, await each page inside the cursor loop, and yield the items instead of pushing them into an array. The caller then sees the first item before the last page is fetched and never holds the whole result set in memory.

solid answer

~50 s

I keep the same cursor loop but change what it does with each page: instead of `results.push(...page.items)`, the function is an `async function*` that awaits the page and then yields each item, looping while the API returns a next cursor. The caller switches from `const all = await fetchAll()` to `for await (const item of paginate())`. Three things change. Time to first result drops from *all* requests to *one*. Peak memory drops from the whole result set to one page, which matters when the collection is large or unbounded. And the consumer controls how far it goes — if it stops after finding what it needed, the remaining pages are never requested at all. The cost is that the caller can no longer treat the result as an array; if it genuinely needs everything, it collects the items itself.

code

javascript · 17 lines
javascript
async function* paginate(fetchPage) {
  let cursor = null;
  do {
    const page = await fetchPage(cursor);
    for (const item of page.items) yield item;
    cursor = page.nextCursor;
  } while (cursor);
}

const fakeApi = async (cursor) =>
  cursor === "p2"
    ? { items: ["c"], nextCursor: null }
    : { items: cursor ? ["b"] : ["a"], nextCursor: cursor ? "p2" : "p1" };

(async () => {
  for await (const item of paginate(fakeApi)) console.log(item); // a, b, c
})();

go deeper

for a junior

Recognise the shape: replace pushing into an array with yield, mark the function async function*, and consume it with for await...of instead of awaiting one big result.

for a middle

Explain why the body only advances on demand, and quantify the change: first result after one request instead of all, memory bounded to a page, unfetched pages skipped on early exit.

for a senior

Bring in the operational consequences — partial failure mid-stream, resources held open across the consumer's processing time, and choosing item versus page granularity for the downstream batch size.

for a principal

Frame it as an API contract decision: what you commit your consumers to by exposing an async iterable rather than a promise of an array, and how that interacts with caching, retries, and idempotence at the boundary.

## The eager version and what it costs The conventional shape is a loop that accumulates: ```js async function fetchAll(fetchPage) { const out = []; let cursor = null; do { const page = await fetchPage(cursor); out.push(...page.items); cursor = page.nextCursor; } while (cursor); return out; } ``` This returns a promise for one array. Its caller therefore waits for every request to finish before it can touch a single item, and the process holds the entire collection at once. With forty pages of a thousand rows, that is forty round trips of latency before anything is displayed and forty thousand objects resident — even if the caller only wanted the first match. ## The lazy version The restructuring is small. Mark the function `async function*` and replace accumulation with `yield`: ```js async function* paginate(fetchPage) { let cursor = null; do { const page = await fetchPage(cursor); for (const item of page.items) yield item; cursor = page.nextCursor; } while (cursor); } ``` Nothing about the request logic changed. What changed is *when* the body runs: it advances only when the consumer asks for another item, and it suspends again at each `yield`. ## What the caller sees ```js for await (const item of paginate(fetchPage)) { process(item); } ``` - **Time to first result** falls from the sum of all requests to the first request. For a UI or a log tail that is the difference between a spinner and a stream. - **Peak memory** is one page plus whatever the consumer keeps, rather than the whole collection. That is what makes an *unbounded* source expressible at all — an endless feed has no array form. - **Early exit is free.** If the consumer stops after finding what it wanted, the generator is suspended mid-loop and the requests for the remaining pages are never issued. In the eager version, the work was already paid for. - **Errors arrive positionally.** A failure fetching page seven rejects the `next()` promise for the item the consumer was pulling at that moment, so it throws out of the `for await...of` loop after six pages' worth of items were already processed. That is usually what you want for streaming, but the consumer must now decide what to do with a half-consumed sequence — a partial-failure question the array version never posed. ## Choosing the granularity Yield items or yield pages? Yielding *items* gives the cleanest consumer (`for await (const user of users())`) and hides pagination entirely. Yielding *pages* is better when the consumer batches — writing a thousand rows per database insert, for example — because rebatching individual items downstream is wasted work. Some APIs expose both: an item-level generator implemented on top of a page-level one. ## The costs, stated plainly You lose the array. `.length`, `.map`, indexing, and passing the result to something expecting an array all stop working. A caller that truly needs the whole set writes its own collection loop, which is the eager version restored — and that is fine; the point is that the *producer* no longer forces that choice on everyone. You also lose the option to fan the page requests out concurrently *inside* the plain shape: the generator body awaits page N before it can compute the cursor for page N+1, so cursor-based pagination is inherently sequential either way. If the API exposes numbered pages and a total count, concurrency becomes possible, but that is then a deliberate design with its own concurrency-limit question — not something the generator gives you for free. Finally, if the fetching step holds a resource — a cursor, a connection, an open handle — laziness means the resource stays open across the consumer's processing time, and it must be released with `try`/`finally` so an early exit does not leak it. ## When the eager version is still right Small, bounded results consumed in full. If the endpoint returns three pages and every caller needs all of them, an array is simpler, is trivially cacheable, and lets callers use ordinary array methods. Laziness is a tool for volume, latency, and early exit — not a default virtue.

  • Would you yield individual items or whole pages, and why?
    Items when the consumer works one record at a time — it hides pagination completely and reads like a normal loop. Pages when the consumer batches, for example writing a thousand rows per insert, because splitting into items only to regroup them is wasted work. A common compromise is a page-level generator with a thin item-level wrapper built on it.
  • A caller says it genuinely needs the full array. Has the generator made things worse for them?
    Barely. They write a short collection loop, or use a helper that drains the iterator into an array, and they are back where the eager function left them. The difference is that the cost is now opt-in: callers who only need the first few pages no longer pay for the rest, and the producer no longer decides for everyone.
  • If a request for page three fails, what does the consumer observe?
    The rejection surfaces on the `next()` promise the consumer is currently awaiting, so it throws out of the `for await...of` loop — after pages one and two were already processed. The consumer needs a policy for that partially consumed state: retry the position, discard the partial work, or record how far it got. The array version failed atomically instead.

saying these in an interview costs you the question

  • Claims the generator fetches pages in parallel
  • Thinks yielding still buffers every page
  • Says memory use is identical to the array version
  • Believes an early break still fetches remaining pages
  • Treats the generator's result as an array

context