Cursor pagination walks through a large result set one page at a time, where each response carries an opaque cursor marking where the next page begins. It is how feeds at GitHub, Stripe, and Slack are paged: instead of asking for "page 5" by number, you hand back the cursor the server gave you last time and it returns the rows that come after it. Your job is to wrap that back-and-forth in a small stateful fetcher that threads the cursor for you, accumulates the items, and knows when it has reached the end.
type Page<T> = { items: T[]; nextCursor: unknown };
function cursorPaginationFetcher<T>(
fetchPage: (cursor: unknown) => Promise<Page<T>>,
): {
next(): Promise<T[]>; // fetch the next page; resolves with THAT page's items
all(): Promise<T[]>; // fetch pages until exhausted; resolves with everything
reset(): void; // clear items + cursor; start over from the first page
items: T[]; // every item gathered so far (live)
hasMore: boolean; // is there at least one more page? (live)
};
The first fetchPage call receives null. A response whose nextCursor is null or undefined marks the last page.
// Three pages: cursors thread through, and a null nextCursor ends it.
const db = {
first: { items: ['a', 'b'], nextCursor: 'c1' },
c1: { items: ['c', 'd'], nextCursor: 'c2' },
c2: { items: ['e'], nextCursor: null },
};
// The first call gets null, so map it to the first page.
const fetchPage = (cursor) => Promise.resolve(db[cursor ?? 'first']);
const fetcher = cursorPaginationFetcher(fetchPage);
await fetcher.next(); // ['a', 'b'] -> items ['a','b'], hasMore true
await fetcher.next(); // ['c', 'd'] -> items ['a','b','c','d'], hasMore true
await fetcher.next(); // ['e'] -> items ['a','b','c','d','e'], hasMore false
await fetcher.next(); // [] -> exhausted, no fetch is made
// all() drains every remaining page in one call; reset() rewinds to the start.
const fetcher = cursorPaginationFetcher(fetchPage);
const everything = await fetcher.all(); // ['a','b','c','d','e']
fetcher.hasMore; // false
fetcher.reset();
fetcher.hasMore; // true again — back to the first page
nextCursor as the argument to the next fetchPage call; the first call gets null.items is the union of every page fetched so far, not just the latest page.hasMore tracks the cursor — it stays true until a response returns a nullish nextCursor (null or undefined), then flips to false.next() is a no-op — once hasMore is false, calling next() resolves with [] and does not call fetchPage.next() versus all() — next() resolves with just the page it fetched; all() resolves with the full accumulated list.next() calls.You'll build a small stateful client that pulls a paginated feed one page at a time — remembering the cursor between calls, piling up the items, and stopping the moment the server says there is nothing left.
You're loading a comment thread with thousands of entries. The API refuses to hand you all of them at once — it gives you a page of, say, twenty, plus a cursor: a small token that means "the next batch starts here." To get the following page you send that token back. Get it wrong and you either re-fetch page one forever, or you throw away everything you already loaded. The fetcher's whole job is to keep that cursor straight and pile up the results as it goes.
Think of a bookmark in a book. Each page ends with a bookmark tucked at the start of the next page, so you never count pages — you just follow the bookmark. The server's nextCursor is that bookmark. You start with no bookmark (null), and when a page comes back with no bookmark at all (nextCursor is null or undefined), you have reached the end.
A reasonable first try just fetches and returns whatever comes back:
function cursorPaginationFetcher(fetchPage) {
let items = [];
async function next() {
const page = await fetchPage(null); // always asks for the first page
items = page.items; // replaces the list instead of growing it
return items;
}
return { next, items, hasMore: true };
}
Two things go wrong, both quietly. Every call passes the same null, so the server keeps returning page one — next() never advances. And items = page.items overwrites the accumulator, so even if the cursor did move you would be left holding only the most recent page, not the union. Nothing reads nextCursor, so hasMore is a frozen true that never learns the feed ended, and the items copied onto the returned object is the empty array captured at creation — it never reflects what you fetched.
Keep three pieces of state in the closure — the accumulated items, the cursor for the next request, and a hasMore flag — and let next() update all three from each response.
function cursorPaginationFetcher(fetchPage) {
let items = []; // every item gathered so far, across all pages
let cursor = null; // where the NEXT page starts; null on the very first call
let hasMore = true; // is there at least one more page to fetch?
async function next() {
if (!hasMore) return []; // exhausted: skip the network, resolve empty
const page = await fetchPage(cursor); // ask for the page at `cursor`
items.push(...page.items); // append this page to the running list
cursor = page.nextCursor; // the response tells us where to go next
hasMore = cursor !== null && cursor !== undefined; // a nullish cursor means "the end"
return page.items; // resolve with just the page we fetched
}
async function all() {
while (hasMore) {
await next(); // pull one page at a time until there are none
}
return items; // hand back everything we accumulated
}
function reset() {
items = [];
cursor = null;
hasMore = true;
}
return {
next,
all,
reset,
get items() { return items; }, // a getter: reads see the LIVE list, not a snapshot
get hasMore() { return hasMore; },
};
}
module.exports = { cursorPaginationFetcher };
The fix lives in next(). It reads the stored cursor and — the crucial line — writes the response's nextCursor back into it, so the following call resumes exactly where this one stopped. It appends instead of overwriting, and it derives hasMore from whether that new cursor is nullish. all() is then just a loop that runs next() until hasMore goes false. Exposing items and hasMore as getters — a getter re-runs its function on every read — is what keeps fetcher.items and fetcher.hasMore live instead of frozen at their empty starting values.
Take the three-page feed from the prompt: fetchPage(null) returns { items: ['a','b'], nextCursor: 'c1' }, fetchPage('c1') returns { items: ['c','d'], nextCursor: 'c2' }, and fetchPage('c2') returns { items: ['e'], nextCursor: null }.
next() — hasMore is true, so we proceed. cursor is null, so fetchPage(null) returns ['a','b'] with nextCursor: 'c1'. We push 'a' and 'b' onto items (now ['a','b']), store cursor = 'c1', and keep hasMore = true ('c1' is not nullish). We resolve with ['a','b'] — just this page. fetcher.items now reads ['a','b'].next() — cursor is 'c1', so fetchPage('c1') returns ['c','d'] with nextCursor: 'c2'. items grows to ['a','b','c','d'], cursor becomes 'c2', hasMore stays true. Resolves with ['c','d'].next() — fetchPage('c2') returns ['e'] with nextCursor: null. items becomes ['a','b','c','d','e'], cursor becomes null, and hasMore flips to false because the cursor is now nullish. Resolves with ['e'].next() — hasMore is false, so we short-circuit: no fetchPage, resolve with [], and fetcher.items is untouched.Calling all() instead of those four manual calls would have run the first three automatically and returned ['a','b','c','d','e'] in one shot.
fetchPage(null) (or fetchPage()) every time pins you to page one forever. Fix: store page.nextCursor after each fetch and pass it as the next call's argument.items = page.items discards earlier pages. Fix: append with items.push(...page.items) so items is the union of all pages.if (cursor) treats a legitimate cursor of 0 or '' as the end. Fix: check for nullish specifically (cursor != null, or !== null && !== undefined).items / hasMore as plain properties — a value copied onto the returned object at creation freezes at the empty start. Fix: use getters so every read returns the current variable.next() guard — without if (!hasMore) return [], a next() after the end re-runs fetchPage(cursor) where cursor is now null, silently re-fetching page one. Fix: short-circuit to an empty array when hasMore is false.next() before the next. If two overlap, both read the same cursor and fetch the same page. Store the in-flight promise and hand it to callers until it settles.fetchPage throws before the state updates, the cursor is untouched and a retry resumes from the same spot. You could wrap the fetch in automatic retry-with-backoff on top of that.isLoading flag or a pageCount so a UI can show a spinner or "page 3 of ?" while pages stream in.AbortController into fetchPage so an in-progress page load can be cancelled when the user navigates away.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Cursor pagination walks through a large result set one page at a time, where each response carries an opaque cursor marking where the next page begins. It is how feeds at GitHub, Stripe, and Slack are paged: instead of asking for "page 5" by number, you hand back the cursor the server gave you last time and it returns the rows that come after it. Your job is to wrap that back-and-forth in a small stateful fetcher that threads the cursor for you, accumulates the items, and knows when it has reached the end.
type Page<T> = { items: T[]; nextCursor: unknown };
function cursorPaginationFetcher<T>(
fetchPage: (cursor: unknown) => Promise<Page<T>>,
): {
next(): Promise<T[]>; // fetch the next page; resolves with THAT page's items
all(): Promise<T[]>; // fetch pages until exhausted; resolves with everything
reset(): void; // clear items + cursor; start over from the first page
items: T[]; // every item gathered so far (live)
hasMore: boolean; // is there at least one more page? (live)
};
The first fetchPage call receives null. A response whose nextCursor is null or undefined marks the last page.
// Three pages: cursors thread through, and a null nextCursor ends it.
const db = {
first: { items: ['a', 'b'], nextCursor: 'c1' },
c1: { items: ['c', 'd'], nextCursor: 'c2' },
c2: { items: ['e'], nextCursor: null },
};
// The first call gets null, so map it to the first page.
const fetchPage = (cursor) => Promise.resolve(db[cursor ?? 'first']);
const fetcher = cursorPaginationFetcher(fetchPage);
await fetcher.next(); // ['a', 'b'] -> items ['a','b'], hasMore true
await fetcher.next(); // ['c', 'd'] -> items ['a','b','c','d'], hasMore true
await fetcher.next(); // ['e'] -> items ['a','b','c','d','e'], hasMore false
await fetcher.next(); // [] -> exhausted, no fetch is made
// all() drains every remaining page in one call; reset() rewinds to the start.
const fetcher = cursorPaginationFetcher(fetchPage);
const everything = await fetcher.all(); // ['a','b','c','d','e']
fetcher.hasMore; // false
fetcher.reset();
fetcher.hasMore; // true again — back to the first page
nextCursor as the argument to the next fetchPage call; the first call gets null.items is the union of every page fetched so far, not just the latest page.hasMore tracks the cursor — it stays true until a response returns a nullish nextCursor (null or undefined), then flips to false.next() is a no-op — once hasMore is false, calling next() resolves with [] and does not call fetchPage.next() versus all() — next() resolves with just the page it fetched; all() resolves with the full accumulated list.next() calls.You'll build a small stateful client that pulls a paginated feed one page at a time — remembering the cursor between calls, piling up the items, and stopping the moment the server says there is nothing left.
You're loading a comment thread with thousands of entries. The API refuses to hand you all of them at once — it gives you a page of, say, twenty, plus a cursor: a small token that means "the next batch starts here." To get the following page you send that token back. Get it wrong and you either re-fetch page one forever, or you throw away everything you already loaded. The fetcher's whole job is to keep that cursor straight and pile up the results as it goes.
Think of a bookmark in a book. Each page ends with a bookmark tucked at the start of the next page, so you never count pages — you just follow the bookmark. The server's nextCursor is that bookmark. You start with no bookmark (null), and when a page comes back with no bookmark at all (nextCursor is null or undefined), you have reached the end.
A reasonable first try just fetches and returns whatever comes back:
function cursorPaginationFetcher(fetchPage) {
let items = [];
async function next() {
const page = await fetchPage(null); // always asks for the first page
items = page.items; // replaces the list instead of growing it
return items;
}
return { next, items, hasMore: true };
}
Two things go wrong, both quietly. Every call passes the same null, so the server keeps returning page one — next() never advances. And items = page.items overwrites the accumulator, so even if the cursor did move you would be left holding only the most recent page, not the union. Nothing reads nextCursor, so hasMore is a frozen true that never learns the feed ended, and the items copied onto the returned object is the empty array captured at creation — it never reflects what you fetched.
Keep three pieces of state in the closure — the accumulated items, the cursor for the next request, and a hasMore flag — and let next() update all three from each response.
function cursorPaginationFetcher(fetchPage) {
let items = []; // every item gathered so far, across all pages
let cursor = null; // where the NEXT page starts; null on the very first call
let hasMore = true; // is there at least one more page to fetch?
async function next() {
if (!hasMore) return []; // exhausted: skip the network, resolve empty
const page = await fetchPage(cursor); // ask for the page at `cursor`
items.push(...page.items); // append this page to the running list
cursor = page.nextCursor; // the response tells us where to go next
hasMore = cursor !== null && cursor !== undefined; // a nullish cursor means "the end"
return page.items; // resolve with just the page we fetched
}
async function all() {
while (hasMore) {
await next(); // pull one page at a time until there are none
}
return items; // hand back everything we accumulated
}
function reset() {
items = [];
cursor = null;
hasMore = true;
}
return {
next,
all,
reset,
get items() { return items; }, // a getter: reads see the LIVE list, not a snapshot
get hasMore() { return hasMore; },
};
}
module.exports = { cursorPaginationFetcher };
The fix lives in next(). It reads the stored cursor and — the crucial line — writes the response's nextCursor back into it, so the following call resumes exactly where this one stopped. It appends instead of overwriting, and it derives hasMore from whether that new cursor is nullish. all() is then just a loop that runs next() until hasMore goes false. Exposing items and hasMore as getters — a getter re-runs its function on every read — is what keeps fetcher.items and fetcher.hasMore live instead of frozen at their empty starting values.
Take the three-page feed from the prompt: fetchPage(null) returns { items: ['a','b'], nextCursor: 'c1' }, fetchPage('c1') returns { items: ['c','d'], nextCursor: 'c2' }, and fetchPage('c2') returns { items: ['e'], nextCursor: null }.
next() — hasMore is true, so we proceed. cursor is null, so fetchPage(null) returns ['a','b'] with nextCursor: 'c1'. We push 'a' and 'b' onto items (now ['a','b']), store cursor = 'c1', and keep hasMore = true ('c1' is not nullish). We resolve with ['a','b'] — just this page. fetcher.items now reads ['a','b'].next() — cursor is 'c1', so fetchPage('c1') returns ['c','d'] with nextCursor: 'c2'. items grows to ['a','b','c','d'], cursor becomes 'c2', hasMore stays true. Resolves with ['c','d'].next() — fetchPage('c2') returns ['e'] with nextCursor: null. items becomes ['a','b','c','d','e'], cursor becomes null, and hasMore flips to false because the cursor is now nullish. Resolves with ['e'].next() — hasMore is false, so we short-circuit: no fetchPage, resolve with [], and fetcher.items is untouched.Calling all() instead of those four manual calls would have run the first three automatically and returned ['a','b','c','d','e'] in one shot.
fetchPage(null) (or fetchPage()) every time pins you to page one forever. Fix: store page.nextCursor after each fetch and pass it as the next call's argument.items = page.items discards earlier pages. Fix: append with items.push(...page.items) so items is the union of all pages.if (cursor) treats a legitimate cursor of 0 or '' as the end. Fix: check for nullish specifically (cursor != null, or !== null && !== undefined).items / hasMore as plain properties — a value copied onto the returned object at creation freezes at the empty start. Fix: use getters so every read returns the current variable.next() guard — without if (!hasMore) return [], a next() after the end re-runs fetchPage(cursor) where cursor is now null, silently re-fetching page one. Fix: short-circuit to an empty array when hasMore is false.next() before the next. If two overlap, both read the same cursor and fetch the same page. Store the in-flight promise and hand it to callers until it settles.fetchPage throws before the state updates, the cursor is untouched and a retry resumes from the same spot. You could wrap the fetch in automatic retry-with-backoff on top of that.isLoading flag or a pageCount so a UI can show a spinner or "page 3 of ?" while pages stream in.AbortController into fetchPage so an in-progress page load can be cancelled when the user navigates away.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.