Many APIs return a long list a page at a time: the first 20 of 10,000 comments, then the next 20, and so on, rather than all of them at once. That's pagination. Each response is small and quick, and you load only as much as someone looks at. The question is how you ask for the next page.
With cursor-based pagination, each page comes with a cursor: a value that marks where that page ended. To get the next page, you send the cursor back. A cursor is opaque, meaning you're not meant to read it, build it, or change it: it might look like "eyJpZCI6NDJ9", and what it stands for is up to the server. You pass it back as it is. On the last page there's no next cursor: it's null, or missing altogether, depending on the API, and that's how you know there's nothing more.
| Request | Response | What it tells you |
|---|---|---|
GET /comments | Comments 1 to 20, and nextCursor: "k7Pq" | There's more: send k7Pq to get it. |
GET /comments?cursor=k7Pq | Comments 21 to 40, and nextCursor: "x2Zr" | There's more: send x2Zr to get it. |
GET /comments?cursor=x2Zr | Comments 41 and 42, and nextCursor: null | That was the last page. |
The other common way is offset pagination: ?offset=20&limit=20 means skip 20 items and send the next 20, and ?page=2 is the same thing counted in pages. It's simple, and it lets you jump straight to page 7. But it counts positions, and positions shift when items are added or removed between your requests. A cursor usually records the item the page ended on, such as its ID or date, so the next page carries on after that item, wherever it has moved. Here's a list shown newest first, 3 to a page. Page 1 was F E D, and then page 2 is asked for:
| Before page 2 is asked for | ?offset=3&limit=3 gives | The cursor after D gives |
|---|---|---|
Nothing changes: F E D C B A | C B A | C B A |
G is added at the top: G F E D C B A | D C B: D is shown twice | C B A |
E is removed: F D C B A | B A: C is never shown | C B A |
A page can come back with no items and still have a next cursor. Some servers read a fixed slice of their data, then filter it, for example leaving out items you aren't allowed to see, and a slice can end up empty while more lies beyond it. So neither an empty page nor a short one means the end: only the missing cursor does.
You meet cursors behind infinite scroll and "Load more" buttons, in feeds that change all the time, and in many public APIs. GraphQL APIs that follow the connection convention, GitHub's among them, take first and after (a cursor) and answer with pageInfo { endCursor hasNextPage }; there, it's hasNextPage: false that marks the last page. The trade-off is that you can't jump to page 7: you reach each page through the cursor of the one before it.
An API returns its results a page at a time. You're given fetchPage(cursor), which returns a promise of a page { items, nextCursor }. items is an array; nextCursor is the cursor for the following page, or null when this is the last page. The first page is fetchPage(null).
Write paginate(fetchPage). It returns an async iterable of every item of every page, in order, so you can loop over all the items with for await...of. Pages with no items are passed over, and it stops after the page whose nextCursor is null.
It fetches each page only when it's needed: nothing until the first item is asked for, and the next page only once every item of the current one has been handed out. When the caller stops early, it fetches no more pages.
If fetchPage rejects, the iteration throws that error, after the items it has already handed out.
const pages = {
first: { items: ["a", "b"], nextCursor: "p2" },
p2: { items: [], nextCursor: "p3" },
p3: { items: ["c"], nextCursor: null },
};
const fetchPage = async (cursor) => pages[cursor ?? "first"];
for await (const item of paginate(fetchPage)) console.log(item);
// logs "a", "b", "c"
const items = paginate(fetchPage)[Symbol.asyncIterator]();
await items.next(); // { value: "a", done: false } (fetches the first page)
await items.next(); // { value: "b", done: false } (no fetch)
await items.next(); // { value: "c", done: false } (fetches p2, then p3)
await items.next(); // { value: undefined, done: true }
const pages = new Map([
[null, { items: [1, 2], nextCursor: "b" }],
["b", { items: [3], nextCursor: "c" }],
["c", { items: [4, 5], nextCursor: null }],
]);
const fetchPage = async (cursor) => pages.get(cursor);
const seen = [];
for await (const item of paginate(fetchPage)) seen.push(item);
expect(seen).toEqual([1, 2, 3, 4, 5]);
const pages = new Map([
[null, { items: [1, 2], nextCursor: "b" }],
["b", { items: [3], nextCursor: "c" }],
["c", { items: [4, 5], nextCursor: null }],
]);
const fetchPage = spy(async (cursor) => pages.get(cursor));
const seen = [];
for await (const item of paginate(fetchPage)) seen.push(item);
expect(fetchPage.calls.map((call) => call.args)).toEqual([[null], ["b"], ["c"]]);
const fetchPage = spy(async () => ({ items: [1, 2], nextCursor: null }));
const items = paginate(fetchPage)[Symbol.asyncIterator]();
await flush();
expect(fetchPage).not.toHaveBeenCalled();
expect(await items.next()).toEqual({ value: 1, done: false });
expect(fetchPage).toHaveBeenCalledTimes(1);
const pages = new Map([
[null, { items: [1, 2], nextCursor: "b" }],
["b", { items: [3], nextCursor: null }],
]);
const fetchPage = spy(async (cursor) => pages.get(cursor));
const items = paginate(fetchPage)[Symbol.asyncIterator]();
await items.next();
await items.next();
await flush();
expect(fetchPage).toHaveBeenCalledTimes(1);
expect(await items.next()).toEqual({ value: 3, done: false });
expect(fetchPage).toHaveBeenCalledTimes(2);
const { value, done } = await items.next();
expect({ value, done }).toEqual({ value: undefined, done: true });
const pages = new Map([
[null, { items: [1, 2], nextCursor: "b" }],
["b", { items: [3], nextCursor: "c" }],
["c", { items: [4, 5], nextCursor: null }],
]);
const fetchPage = spy(async (cursor) => pages.get(cursor));
const seen = [];
for await (const item of paginate(fetchPage)) {
seen.push(item);
if (item === 3) break;
}
await flush();
expect(seen).toEqual([1, 2, 3]);
expect(fetchPage).toHaveBeenCalledTimes(2);
const pages = new Map([
[null, { items: [], nextCursor: "b" }],
["b", { items: [], nextCursor: "c" }],
["c", { items: ["x"], nextCursor: null }],
]);
const seen = [];
for await (const item of paginate(async (cursor) => pages.get(cursor))) seen.push(item);
expect(seen).toEqual(["x"]);
const empty = [];
for await (const item of paginate(async () => ({ items: [], nextCursor: null }))) empty.push(item);
expect(empty).toEqual([]);
const error = new Error("network down");
const fetchPage = async (cursor) => {
if (cursor === null) return { items: [1, 2], nextCursor: "b" };
throw error;
};
const seen = [];
let caught;
try {
for await (const item of paginate(fetchPage)) seen.push(item);
} catch (e) {
caught = e;
}
expect(seen).toEqual([1, 2]);
expect(caught).toBe(error);
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Async iterator over a paginated API. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.