Queriescursorkeysetopaque tokenstable orderingtraversal

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.

▶ Run the labFollow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
What exactly does the continuation token encode, what does it promise across time and deploys, and what happens when it is stale?
Consumers
Feeds and infinite scroll resuming where the user left off; sync engines and webhook-backfill jobs that must see every record exactly once; SDK pagination helpers (`for await (const item of list())`) that hide the token entirely; data pipelines that store a cursor and come back tomorrow.
The promise
A well-designed cursor contract guarantees flat-cost continuation from a stable position under concurrent writes, an opaque token whose internals can change without breaking anyone, a stated lifetime, and a defined error when the token is stale or invalid.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

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).

The query a cursor compiles to — a seek, not a skip
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, total
5FROM orders
6WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
7ORDER BY created_at DESC, id DESC
8LIMIT 51; -- limit+1: the 51st row's existence = has_more
9
10-- served by: INDEX (created_at DESC, id DESC)
11-- page 1 and page 20,001 cost the same: one seek + 51 rows

Opacity 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.

A transparent cursor — internals as API
1GET /orders?cursor=eyJvZmZzZXQiOjEwMH0
2# 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 wild
8
9# every internal detail is now load-bearing;
10# changing the encoding is a breaking change
An opaque, signed keyset cursor
1GET /orders?cursor=djEuc2lnbmVkLtGh
2
3# server-side the token decodes (and verifies) to:
4# v: 1 # token format version
5# k: [ts, id] # keyset values
6# o: created_at_desc # ordering id
7# f: 6d2a… # hash of active filters
8# 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.

Pagination Under Concurrent Writes
Both clients page the same newest-first list, 4 per page. Insert records between fetches and watch offset drift while the cursor holds.
Offset client — seen so far
2019181716151413121110987654321
Cursor client — seen so far
2019181716151413121110987654321
Request log (newest first)

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.

  1. 1
    Team → API: ships "cursor" pagination by base64-encoding {"offset": N} — the envelope looks modern, the semantics are still offset.
  2. 2
    Client → token: decodes it out of curiosity, starts constructing tokens to parallelize a backfill.
  3. 3
    Team → 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.
  4. 4
    Sync consumers → API: get 500s (not a designed invalid_cursor) for stored tokens; each invents its own recovery, some by restarting, some by retrying forever.
  5. 5
    Provider → support: cannot tell forged tokens from stale ones in the logs; the incident review adds the signing and versioning that v1 skipped.
What breaks
  • 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.

Design the contract
  • • 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.
Observe in production
  • • 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.
Evolve without breaking
  • • 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.
What it costs
  • • 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

Claim
“A cursor is just an encoded offset — the encoding is cosmetic.”
Reality
An encoded offset keeps O(offset) cost and boundary drift; only a keyset (values, not positions) buys the flat cost and write stability that justify cursors. If decoding your cursor reveals a number that grows by limit each page, you have offset pagination with worse ergonomics.
Claim
“Base64 makes the cursor opaque.”
Reality
Base64 is an encoding, not a seal — every developer decodes it reflexively, and then your internals are API. Opacity that survives contact with clients requires a signature: tokens you did not mint get rejected, so nothing clients build on the internals can ever work.
Claim
“Cursor pagination guarantees consumers see a consistent snapshot.”
Reality
It guarantees a stable frontier along the ordering key: no repeats, no gaps at boundaries. Rows already traversed can be updated or deleted behind the cursor, and rows inserted behind it will not be revisited. Point-in-time consistency is an export/snapshot feature, and the docs should say which promise you make.