Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°63

API Pagination Collector

Easy FDEIntegrationsAPIs

The customer’s API returns 100 rows at a time behind a cursor. You need all of them. Follow the "next" pointer page by page until it runs out — the loop behind every "sync everything" integration. Solve it in Python or TypeScript, with hidden tests.

The problem

Implement collect(pages). pages models a cursor-paginated API as a list where each entry is {"items": [...], "next": index-or-None}. Start at index 0, append its items, follow next to the next page, and repeat until next is None. Return the flat list of all items in page order. (Guard against a cycle: never visit the same page twice.)

SOLVE IT YOURSELF

Your turn — write it

Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.

YOUR TASK

Implement collect(pages): walk from index 0 following next, extending an output list with each page’s items, tracking visited indices to break any cycle.

CPython · WebAssembly
Examples and constraints 4 examples
EXAMPLE 1
Input pages = [{"items":[1,2],"next":1},{"items":[3],"next":2},{"items":[4,5],"next":null}]
Output [1, 2, 3, 4, 5]
follow next: page 0 → 1 → 2, then stop
EXAMPLE 2
Input pages = [{"items":[1],"next":2},{"items":[99],"next":null},{"items":[2],"next":null}]
Output [1, 2]
next decides the order, not the list: page 1 is never reached, so 99 is not collected
EXAMPLE 3
Input pages = [{"items":[],"next":1},{"items":[7],"next":null}]
Output [7]
an empty page is not the end; keep following next
EXAMPLE 4
Input pages = [{"items":[1],"next":1},{"items":[2],"next":0}]
Output [1, 2]
edge case: page 1 points back to page 0, so the visited set stops the loop
CONSTRAINTS
  • Begin at index 0 and follow next until it is None/null.
  • Concatenate each page’s items in the order visited.
  • Stop if you revisit a page (a malformed API could loop) — do not spin forever.
Hints 3 ideas
HINTS — 3 IDEAS
  1. Keep a current index (start 0) and a seen set of visited indices.
  2. While the index is not None and not already seen: mark it seen, extend the output with pages[i]["items"], set i to pages[i]["next"].
  3. Return the accumulated items.
Approach, complexity & discussion — open after you solve

Start from brute force

The first instinct is to ignore the cursor and concatenate the pages in list order. That is one pass, O(N) time and O(N) space for N items, and it is wrong: the order and the set of pages come from next, so an unreachable page leaks in and a chain like 0 → 2 → 1 comes out scrambled. The second instinct follows next with while i is not None and nothing else. On a healthy chain that is correct at the same cost. On a malformed API whose cursor points back at an earlier page it never terminates: it re-reads the same pages forever and out grows until memory runs out.

The approach

Treat the pages as a linked list: index 0 is the head and next is the pointer. The reference collect keeps three pieces of state: i, the page about to be read (starting at 0); out, the items gathered so far; and seen, the set of page indices already read. The loop runs while i is not None and i not in seen. Each pass adds i to seen, extends out with pages[i]["items"], and moves to pages[i]["next"].

The invariant is that out holds exactly the items of the pages in seen, in the order they were reached. Every pass adds a new index to seen, so the loop runs at most once per page and terminates on any input, including a page that points at itself. The explicit is not None test matters too: page 0 is a valid cursor, and a bare while i: would stop before reading anything. The TypeScript version is the same loop with i !== null and out.push(...pages[i].items).

Walk through an example

On the first example the loop reads three pages, then stops when next comes back None.

stepiseen beforeactionout after
10{}read [1, 2]; next is 1[1, 2]
21{0}read [3]; next is 2[1, 2, 3]
32{0, 1}read [4, 5]; next is None[1, 2, 3, 4, 5]
4None{0, 1, 2}loop condition fails; return out[1, 2, 3, 4, 5]

Complexity

O(P + N) time for P pages reached and N items copied: each page is read once behind an average-O(1) set check, and each item is copied once. O(N + P) space for out and seen. The unguarded loop costs the same on a healthy chain but has no bound on a cycle; list-order concatenation is O(N) and simply returns the wrong list.

Edge cases to test

  • [{"items":[1],"next":0}] → [1]. A page that points at itself; without seen the loop never ends.
  • [{"items":[1],"next":1},{"items":[2],"next":0}] → [1, 2]. A two-page cycle: stop on the revisit and return what was gathered, with no duplicates.
  • [{"items":[],"next":1},{"items":[7],"next":null}] → [7]. An empty page is not the last page; stopping on empty items drops data.
  • [{"items":[1],"next":2},{"items":[99],"next":null},{"items":[2],"next":null}] → [1, 2]. Page 1 is unreachable; list-order concatenation wrongly includes 99.
  • [{"items":[9],"next":null}] → [9]. The start cursor is 0, which is falsy, so while i: returns [] here and on every other input.
  • [{"items":[],"next":1},{"items":[],"next":null}] → []. Every page is empty: return an empty list, not None.

Common mistakes

  • An infinite loop if the next token never clears — guard with a max-pages cap or a seen-cursor check.
  • Dropping the final page’s items by stopping one iteration too early.
  • Assuming a fixed page count instead of following the cursor until it ends.

Follow-ups an interviewer asks next

The cursor is an opaque string from a real HTTP API.
The loop does not change. Request the endpoint with the current cursor, extend the output with the response items, and read the next cursor from the body; seen becomes a set of cursor strings. Then add what a network brings: retry 429 and 5xx responses with backoff, honor a Retry-After header when the API sends one, and cap the total pages so a server bug cannot run the job forever.
The full result is too large to hold in memory.
Make the collector a generator that yields each page’s items as they arrive, so memory is one page plus the seen cursors instead of the whole data set. After each page is fully processed, persist its cursor as a checkpoint; if the job crashes it resumes from there instead of starting over and re-sending everything downstream.
Can you fetch the pages in parallel?
Not along one cursor chain: each response is the only place the next cursor appears, so the requests are sequential by construction. Offset pagination lets you compute every page URL up front and fetch them concurrently, but inserts and deletes during the sync shift the offsets and cause skipped or repeated rows. To parallelize safely, split the data into independent ranges, such as creation-date windows, and follow one cursor per range.
Should a repeated cursor return partial data or fail?
This problem asks you to stop, which is the safe minimum, but stopping quietly makes a broken sync look successful while rows are missing. In production, treat a repeated cursor as a server bug: raise or log an error that names the cursor and the page count, so the run is marked failed and can be retried or reported to the API owner.

Where this shows up

This is the loop behind every “sync everything” integration: the customer’s API returns 100 rows at a time behind a cursor and you need all of them. Cursor-following is the robust pagination pattern — unlike offset pagination, it does not drift or skip rows when the data changes underneath you mid-sync.

Reference solution

Reference solution — Python
def collect(pages):
    out = []
    i = 0
    seen = set()
    while i is not None and i not in seen:
        seen.add(i)
        out.extend(pages[i]["items"])
        i = pages[i]["next"]
    return out


print(collect([{"items":[1,2],"next":1},{"items":[3],"next":2},{"items":[4,5],"next":None}]))
# -> [1, 2, 3, 4, 5]
Reference solution — TypeScript
function collect(pages: any[]): any[] {
  const out: any[] = [];
  let i: number | null = 0;
  const seen = new Set<number>();
  while (i !== null && !seen.has(i)) {
    seen.add(i);
    out.push(...pages[i].items);
    i = pages[i].next;
  }
  return out;
}

console.log(JSON.stringify(collect([{items:[1,2],next:1},{items:[3],next:2},{items:[4,5],next:null}])));
// -> [1,2,3,4,5]

Questions about API Pagination Collector

What is cursor pagination and how is it different from offset pagination?

Offset pagination asks for rows by position, such as offset 200 with a limit of 100. Cursor pagination returns an opaque token with each page, and you pass that token back to get the next one. The difference shows up when data changes during a sync. With offsets, a row inserted or deleted near the front shifts every later position, so page boundaries move and rows get skipped or repeated. A cursor usually encodes where the last page ended, such as the last id returned, so the next page starts after that row no matter what changed before it.

How do you stop an API pagination loop from running forever?

Stop on the documented end signal, a missing or null next cursor, and add two guards against a misbehaving server. First, remember every cursor you have already requested in a set, and stop or raise an error when one repeats, because a repeat means the chain has a cycle. Second, cap the number of pages or the total run time, which also catches a server that keeps inventing new cursors. The set check costs O(1) per page on average, and the cap bounds the damage of anything the set cannot see.

Why does the solution test i is not None instead of writing while i?

Because the first cursor is page 0, and 0 is falsy in both Python and JavaScript. A loop written as while i, or while (i) in TypeScript, reads 0 as false and exits before fetching anything, so it returns an empty list for every input. The same trap appears when a next pointer leads back to page 0. Comparing against None in Python, or using a strict inequality against null in TypeScript, separates the real end signal from a valid index that happens to be zero.

Should an empty page end pagination?

No. The end of the data is signaled by the next cursor being absent, not by an empty items list. In this problem the first page can be empty and still point at a page that holds data, and an API is free to do the same, for example when rows were filtered out of a page after it was cut. Stopping on an empty page silently drops everything after it. Keep following next until it is null, and treat an empty page as a page with nothing to add.

Finished this one? 0 / 76 Challenges done
NextIdempotency Key Handler Next on the “Integrations & messy data” ladder · rung 2 of 10

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Challenges