Cursor Pagination FetcherLoading saved progress…

Cursor Pagination Fetcher

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.

Signature

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.

Examples

// 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

Notes

  • Thread the cursor — pass the previous response's nextCursor as the argument to the next fetchPage call; the first call gets null.
  • Accumulate, do not replaceitems 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.
  • An exhausted 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.
  • Do not worry about concurrency — assume each call is awaited before the next; you need not guard against overlapping next() calls.

FAQ

What does a null nextCursor mean?
It marks the last page — there are no more results. The fetcher flips hasMore to false, and any further next() call becomes a no-op that resolves with an empty array without hitting the network.
How is cursor pagination different from offset pagination?
Offset pagination asks for page N by skipping N times the page size, which drifts and can duplicate or skip rows when the data changes mid-scroll. Cursor pagination passes an opaque pointer to the last row seen, so it stays stable and is cheaper for the database to resume from.
Why expose items and hasMore as getters instead of plain properties?
A plain property copies its value once when the object is created, so it would freeze at the empty starting state. A getter re-reads the live variable on every access, so callers always see the current accumulated list and the up-to-date flag.
Loading editor…