API pagination stays consistent only when the server defines a total order, treats cursors as opaque positions and explains whether a multi-page walk is live, bounded or tied to a snapshot. Without that contract, inserts and deletes between requests can create skips, duplicates or results that do not represent one dataset.
Start with a total order
A list endpoint needs an order that can place every record unambiguously. Sorting by a timestamp alone is usually not enough: two records can share the same timestamp. Add a stable tie-breaker, such as an immutable ID.
A useful order might be:
created_at DESC, id DESC
The cursor should carry both values. If it carries only the timestamp, the server has no reliable way to continue inside a group of records created at the same instant.
The same rule applies to ascending lists. The exact direction matters less than the promise that one item cannot move around because the comparison is incomplete.
A cursor is a position, not a page number
GitHub’s REST documentation shows several pagination shapes. Some endpoints use page numbers, while others use before, after or since. GitHub recommends following the URLs in the response’s Link header instead of constructing the next page by hand. It also notes that most endpoints cap per_page at 100.
Stripe’s v1 list APIs use object IDs as cursors through starting_after and ending_before. The two parameters are mutually exclusive, and the response includes has_more to tell a client whether another page exists.
Both examples make an important point: the server owns the continuation contract. A client should treat the returned next link or cursor as opaque. Rebuilding it from visible fields couples the client to implementation details and makes later changes much harder.
Decide what happens when the list changes
A cursor preserves a position in an order. It does not automatically preserve a snapshot.
Imagine a client fetching the first 50 newest orders. Before the second request, three newer orders arrive. With a correctly implemented keyset cursor, those new records sit before the client’s current position and do not create duplicates in the rest of the walk. That is often the right behavior for an activity feed.
Now imagine an export that promises an exact account balance report. “Everything that happened to be in each page when we asked” is not a useful definition. The export needs a time boundary or a stable snapshot.
There are three common contracts:
- Live walk. Each page uses current data, but continuation remains stable relative to the last item. New records may be outside the remainder of the walk.
- Bounded walk. The first response fixes an upper boundary such as
created_at <= 2026-10-15T07:00:00Z. Every later page repeats or embeds that boundary. - Snapshot walk. The server keeps a database or materialized snapshot and issues a snapshot token. Every page reads the same logical dataset until the token expires.
The right choice depends on the job. A search result can tolerate a live walk. A billing export often cannot.
Database isolation does not automatically span HTTP requests
PostgreSQL documents that under its default Read Committed isolation, two successive SELECT statements can see different committed data. Repeatable Read gives a transaction a stable view from its first statement, but a normal paginated API usually opens a new transaction for every HTTP request.
Keeping one database transaction open across a long client session is rarely a good general solution. It holds resources, complicates failure recovery and can outlive practical connection limits.
If the product needs snapshot semantics, design them explicitly. Options include recording an “as of” boundary, materializing export rows, or saving a server-side snapshot reference with a documented expiry.
Deletions need a rule too
A keyset cursor normally continues after the last key it saw. If that anchor record is deleted, the cursor can still work when it contains the comparison values rather than requiring the row to exist.
That is another reason not to make a cursor only an encoded database ID. The server may need the sort key, direction, filters and snapshot boundary to reconstruct the next query safely.
Do not put sensitive filter values in a readable token. Sign or encrypt cursors as appropriate, and reject tampered or expired tokens with a clear error.
Keep filters and permissions inside the cursor contract
A continuation token from “paid invoices for customer A” should not be reusable for “all invoices for customer B.” Either embed the normalized query scope in the signed token or verify it against server-side state.
Authorization can also change between requests. The API has to decide whether a snapshot preserves the original authorized set or rechecks access on every page. Rechecking is safer for revoked access, but it may change totals. Document the behavior instead of leaving clients to infer it.
This is closely related to reviewing an OpenAPI contract before release. Cursor formats may be opaque, yet changing ordering, default filters or continuation behavior can still break a client.
What to test before release
Use a test dataset with deliberate ties in the sort field, then mutate it between requests.
Check that:
- every original record appears once in a bounded or snapshot walk;
- a live walk never loops or duplicates an already returned item;
- inserts before and after the cursor behave as documented;
- deletion of the anchor record does not break continuation;
- changing the page size preserves position;
- expired, malformed and cross-scope cursors fail clearly;
- the final response has an unambiguous end condition;
- retrying the same request returns a compatible page.
Also test large walks without collecting every item in memory. GitHub’s Octokit documentation, for example, offers an iterator so clients can process one page at a time.
A practical response shape
A small, explicit envelope is easier to evolve than a bare array:
{
"data": [],
"next_cursor": "opaque-token",
"has_more": true,
"as_of": "2026-10-15T07:00:00Z"
}
Do not include as_of unless it means something precise. If it is only the response time, name it accordingly. A timestamp that looks like a snapshot promise but is not one creates more confusion than it removes.
The design test is simple: can the API explain what a complete walk represents while records are being inserted, updated and deleted? If the answer is vague, the pagination contract is unfinished.





