This is the last part of our Pagination series. Part 1 covered index-based paging, Part 2 cursors, Part 3 range walks and the truncation anchor. This part is the checklist that falls out of them, split by who holds the pen.
If you design the API
- Key the cursor, not the position. Expose
after=<key>orbefore=<key>, never onlypage/offset. A key cursor survives appends (Part 2); an index does not (Part 1). Page numbers are fine as a convenience beside it, not instead of it. - Say which end you fill from, and let the client choose. Document the anchor in one sentence. Better, accept both
beforeandafterso the client picks the direction and the question disappears. - Make "more" explicit. Return
has_more, or a next cursor that is absent exactly when the walk is over. A short page is a poor signal: sparse data produces one mid-series, and a page that happens to be exactly full looks truncated. - Document the cap and the default, as numbers. Both belong next to the size parameter, and the default must be the one the server really applies when the parameter is omitted.
- Break ties. If the cursor key is not unique (a millisecond, a block height), accept a unique secondary key as part of the cursor, or cursor on a unique id and let time be a filter. Otherwise every client needs Part 2's inclusive-plus-dedup machinery and still hits the unresolvable case.
- Truncate, do not refuse. A range holding more rows than the cap should return the cap from the anchored end. Rejecting it forces every client to guess a width from row density (Part 3).
- State the bounds' inclusivity. Closed or half-open, either is fine; not knowing is what costs clients.
- Make paged reads safe to repeat. A client retrying one page re-issues its request. An endpoint that mutates state on read (marking as seen, consuming) cannot be walked safely.
If you write the client
- Model the walk as a step function.
next(state) -> (rows, state'), with everything the walk needs insidestate(index, cursor, moving bound, carried rows), nothing in closure variables, nothing read from the clock. - Retry the page, not the walk. With a pure step, a transient failure is
retry(lambda: next(state))and a resumed walk is the same loop started from a savedstate. A generator that raised can do neither. - Deduplicate by identity. On a live index-paged endpoint (Part 1) and on a non-unique key cursor (Part 2), rows come back. Drop them by id where one exists, by content otherwise, knowing that content identity fails on rows that change between requests.
- Take the extreme key, not the last row. Page order on the wire is not guaranteed to match the walk direction, and on some venues never does.
- Treat the empty page as the one universal terminator. A short page needs a known cap. A total is a stop condition, never an integrity check: a total that moves mid-walk is the append case, not a fault.
- Never guess the anchor or the cap. Measure the anchor with a wide range and a small size, far from the live edge (Part 3). Measure a default cap by omitting the size parameter and counting. Write both down next to the endpoint, dated.
- Refuse the unresolvable case loudly. A full page whose rows all share the cursor key cannot be advanced past. Raise and name the bound; a silent skip is the one outcome worse than an exception.
- Advance offsets by rows received. Never by the size you asked for.
The walk, in one shape
Every method in this series fits one contract:
init : S # the first page's state
next : S -> (rows, S | done) # pure in S
Page and offset carry an integer, token an opaque value, key cursor a key, range walk a (moving bound, carried rows) pair. A caller who only wants the rows iterates; one who wants resilience wraps next; one who wants to stop and continue tomorrow stores S. None of that is possible when the walk is a loop the client cannot see inside.
Closing
Pagination looks like plumbing, and most of the time it is. The failures that matter are the quiet ones: a walk that returns cleanly with a fraction of the data, and a caller with no way to tell. Everything in this series reduces to avoiding that: address rows by key, measure the facts the documentation leaves out, and write the walk as a step the client can retry, resume and inspect.