Cursor Pagination: An Opaque Bookmark, Not a Position
A cursor is the server saying "resume after this row" in a token the client stores but never reads. Done right it makes deep traversal flat-cost and write-stable; done lazily it leaks internals, breaks on deploys, and quietly becomes offset with extra steps.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
The mechanism: keyset seek behind an opaque token
Strip the encoding away and a cursor is usually a keyset: the ordering-key values of the last row served. "Resume after (created_at = 2026-07-01T09:14:07Z, id = ord_8231)" compiles to a WHERE clause the database answers with an index seek — land on the boundary, read the next 50 rows, done. Page 2,001 costs what page 1 costs, because the query never mentions a position, only values. This is the entire performance story: cursors are fast because they replace "skip 100,000 rows" with "seek to a key", the operation B-tree indexes exist to make O(log n).
The mechanism imposes one hard requirement the contract must honor: a total, deterministic ordering. If two rows share a created_at and the sort ends there, "after row X" is ambiguous — ties can be returned in either order, and a page boundary that lands inside a tie duplicates or skips rows. Every cursor ordering therefore ends with a unique tiebreaker (, id), and the composite index must match the full ordering, including direction. The contract clause "sorted by created_at" carries a physical bill: one composite index per supported ordering (see Sorting: Determinism or Drift and Filtering: An Allowlist With an Index Bill for how the combinatorics escalate).
Stability under writes follows from the same design: the cursor names *values*, not positions, so inserts and deletes elsewhere in the collection cannot shift it. New rows sort before or after the frontier and are seen or not seen accordingly — the traversal never repeats and never gaps along the ordering key. The one caveat worth documenting: this is frontier stability, not a snapshot. Rows already behind the cursor can be updated or deleted after you read them; consumers needing point-in-time consistency need export semantics, not a list endpoint (see Consistency as a Contract Clause).
1-- ordering: created_at DESC, id DESC (total, deterministic)2-- cursor decodes to: ('2026-07-01T09:14:07Z', 'ord_8231')3 4SELECT id, created_at, status, total5FROM orders6WHERE (created_at, id) < (:cursor_created_at, :cursor_id)7ORDER BY created_at DESC, id DESC8LIMIT 51; -- limit+1: the 51st row's existence = has_more9 10-- served by: INDEX (created_at DESC, id DESC)11-- page 1 and page 20,001 cost the same: one seek + 51 rowsOpacity is the contract's load-bearing wall
The token must be opaque: documented as a value the client stores and returns, never inspects, never constructs. This is not secrecy for its own sake — it is the seam that keeps your internals evolvable. The moment a client base64-decodes your cursor and discovers {"created_at": "...", "id": "..."}, someone will construct their own tokens, and your ordering keys, encoding and even column names have become public API. Now switching the tiebreaker, adding a shard hint, or fixing a timezone bug in the cursor breaks "consumers" you never knew you had — Hyrum's Law with a decoder ring (see What an API Contract Actually Is).
Practical opacity is cheap: encode the keyset, then sign or HMAC it so tampered and hand-built tokens are rejected as invalid_cursor rather than executed. Signing also closes the quiet security hole in naive cursors: a client that can forge keyset values can walk orderings and ranges your filters never offered, and a cursor that embeds a raw SQL fragment (it has been shipped) is an injection surface. The token should carry data — key values, ordering id, maybe filter hash — never anything executable, and the server should validate that the cursor's embedded ordering and filters match the request's (a cursor from ?status=paid replayed against ?status=refunded must fail loudly, not return plausible garbage).
Finally, the contract must answer the lifecycle questions clients will otherwise answer by assumption. Lifetime: keyset cursors are naturally durable (values do not expire), but if yours embed snapshot state or server-side context, say how long they live. Staleness: what happens when the anchor row was deleted? (Keyset answers gracefully — the seek lands after where it would have been.) Invalidity: a malformed, expired or mismatched cursor returns a documented 400 invalid_cursor, and the client's recovery — restart from the beginning — should be written in the docs, because a sync engine needs to know that restarting is the *designed* behavior, not a bug workaround.
1GET /orders?cursor=eyJvZmZzZXQiOjEwMH02# base64 of {"offset": 100} —3# an offset in a trench coat: O(n) cost,4# drift, AND clients now craft their own:5 6GET /orders?cursor=base64({"offset": 999999})7GET /orders?cursor=base64({"sql": "id > 5"}) # seen in the wild8 9# every internal detail is now load-bearing;10# changing the encoding is a breaking change1GET /orders?cursor=djEuc2lnbmVkLtGh…2 3# server-side the token decodes (and verifies) to:4# v: 1 # token format version5# k: [ts, id] # keyset values6# o: created_at_desc # ordering id7# f: 6d2a… # hash of active filters8# tampered / mismatched / unknown-version →9# 400 { "code": "invalid_cursor",10# "message": "Restart pagination from the first page." }The bad cursor makes three mistakes at once: it is an offset (inherits the cost and drift), it is readable (internals become contract), and it is trusted (client-built tokens reach the query layer). The good token is a sealed claim: versioned, signed, checked against the request — so its internals can change on any deploy without any client noticing.
What cursors cost, and designing around it honestly
Cursors give up random access, and the contract should be honest about the consequences rather than hand-waving them. No jumping to page 40; no "page 7 of 132" (there are no page numbers, and totals are a separate, separately-priced question); no trivially parallelizable traversal — page N+1's request needs page N's response. For most products these are non-features. When one of them is real, solve it deliberately: parallel export via partitioned cursors (the server hands out N range-disjoint cursors, one per worker), positional UIs via bounded Offset Pagination: Simple, Jumpable, and Lying Under Writes on that one admin surface, totals via a dedicated count endpoint with stated staleness.
Bidirectional traversal is the other buildable-but-not-free feature. A feed that lets users scroll up needs a prev_cursor alongside next_cursor, which means the server reverses the ordering and the comparison for backward seeks and un-reverses the page before returning it — mechanical, but a real second code path that deserves tests, because backward-pagination bugs (reversed pages, off-by-one at the frontier) are embarrassingly common. Ship prev_cursor when a consumer needs it, not speculatively; every emitted cursor field is contract you maintain forever.
- No random access: page N+1 requires page N — parallel exports need server-issued partitioned cursors instead.
- No page numbers or cheap totals: offer
has_more, and a separate count endpoint only if the product truly needs it. - Backward traversal (
prev_cursor) is a deliberate second code path — build it for a named consumer, test the frontier math. - Every ordering offered is a composite index owned forever; keep the supported orderings list short (see Sorting: Determinism or Drift).
- Document the recovery rule:
invalid_cursor→ restart from the first page. Sync engines need that in writing.
Key points
- A cursor is (usually) a keyset — the last row's ordering-key values — compiled to an index seek: flat cost at any depth, stable under concurrent writes.
- Cursor orderings must be total and deterministic: always a unique tiebreaker, always a matching composite index — the contract clause has a physical bill.
- Opacity is load-bearing: sign the token, reject tampering as
invalid_cursor, and internals stay evolvable on any deploy. - Validate the cursor against the request: a token replayed under different filters or ordering must fail loudly, not return plausible garbage.
- Cursors buy stability by giving up random access, page numbers and cheap totals — solve those deliberately where they are real requirements.
- Stability means a clean frontier, not a snapshot: rows behind the cursor can still change after you read them.
Pagination Under Concurrent Writes
Change the contract and observe which guarantee moves.
—
Offset re-counts from the top every request, so each insert shifts the window back over rows the client already saw. The cursor names a position in the ordering, so new rows above it are simply ignored.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → API: ships "cursor" pagination by base64-encoding
{"offset": N}— the envelope looks modern, the semantics are still offset. - 2Client → token: decodes it out of curiosity, starts constructing tokens to parallelize a backfill.
- 3Team → deploy: switches the token to a real keyset format; every hand-built client token breaks overnight — and so do stored real ones, because nothing was versioned.
- 4Sync consumers → API: get
500s (not a designedinvalid_cursor) for stored tokens; each invents its own recovery, some by restarting, some by retrying forever. - 5Provider → support: cannot tell forged tokens from stale ones in the logs; the incident review adds the signing and versioning that v1 skipped.
- Transparent tokens turn internals into contract: encoding, ordering keys and column names all become unremovable once clients decode and depend on them.
- Unversioned tokens break every in-flight traversal on deploy — feeds jump, sync jobs crash mid-backfill, stored cursors from yesterday are garbage today.
- Missing tie-breakers corrupt quietly: page boundaries landing inside timestamp ties duplicate and skip rows, which for sync consumers is data corruption with all-2xx logs.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Encode the keyset opaquely — versioned, signed, carrying ordering id and a filter hash — and reject any mismatch as a documented `400 invalid_cursor` with a stated recovery (restart).
- • Guarantee total ordering with a unique tiebreaker and back every supported ordering with a matching composite index before it ships.
- • Fetch limit+1 to compute `has_more` without a count query; return `next_cursor: null` as the explicit end-of-collection signal.
- • State the token's lifetime and staleness behavior (deleted anchor row, filter changes) in the docs — sync engines build on those sentences.
- • Track `invalid_cursor` rates by cause (bad signature vs unknown version vs filter mismatch): signatures failing means forgery or corruption; versions failing right after a deploy means your migration window was too short.
- • Watch traversal completion: sync consumers that start but never reach `next_cursor: null` are hitting mid-traversal failures your endpoint metrics average away.
- • Verify every cursor query's plan is an index seek (no filesort, no scan) per supported ordering — one missing composite index turns flat cost back into offset cost silently.
- • Version the token format from day one (`v1.…`); accept old versions during a migration window, then reject them as `invalid_cursor` — traversals restart, nothing 500s.
- • New orderings and filters are additive: new cursor contents ride inside the same opaque envelope, old tokens keep working for old queries.
- • Because the token is opaque, its internals — encoding, compression, added shard hints — can change on any deploy; this is the dividend opacity pays for the discipline it cost.
- • Signing, versioning and request-matching are real machinery that offset never needed; for a 60-row internal list, that machinery is over-engineering (see [[api-design-principles]]).
- • Each supported ordering costs a composite index: storage, write amplification, and a migration to add — the reason cursor APIs offer three sort options, not thirty.
- • The client model is stateful: consumers must persist tokens and implement restart-on-invalid, which is more to get wrong than incrementing a page number — your SDK's iterator helper should own it (see [[sdk-design]]).
Misconceptions
limit each page, you have offset pagination with worse ergonomics.