pattern

Cursor Opacity

also called Opaque Continuation Token, Sealed Cursor

Making a pagination cursor an untyped token the client cannot construct or interpret, so the server keeps the freedom to change what it encodes without breaking callers.

paginationcursorsapi designversioningcoupling

A cursor is a position in a result set. The temptation is to make it legible — a base64 timestamp, or a JSON blob with the last id — because it is easier to debug.

The moment a cursor is legible, some client will construct one. They will decode it, edit the timestamp, and build their own to skip ahead or resume from a position they computed. From then on the cursor's internal structure is a public interface you never intended to publish, and every change to the sort order, the filter semantics or the shard layout is a breaking change.

Opacity is not about secrecy. It is about retaining the right to change the encoding, which is the only reason cursors are preferable to offsets in the first place.

Why it matters

A cursor encodes assumptions that will change: the sort key, the filter set, the shard routing, the tie-break column. Keyset pagination works because the server can build a predicate from the cursor, and that predicate is only valid for the query shape the cursor was issued under.

When the query shape changes and the cursor does not know it, the naive implementation does not error. It builds a syntactically valid, semantically meaningless predicate and returns a 200 with an arbitrary subset, duplicates and gaps included. An export job consuming that produces an incomplete file indistinguishable from a complete one.

Implementation patterns

  • Encode a contract version inside the cursor — sort key, filter set, schema version — and reject on mismatch with a specific machine-readable error such as cursor_invalidated, naming the remedy.
  • Integrity-protect it. Sign or authenticate the token so a tampered or handcrafted cursor is rejected rather than interpreted.
  • Give it a TTL — 3600 seconds to 86400 seconds covers most real resumption patterns — and return an explicit expiry error rather than silently restarting at page one.
  • Never encode anything sensitive, even encrypted, because a cursor is stored in client logs, URLs and browser history.
  • Document it as opaque in the reference, with the sentence that matters: treat it as a string, store it whole, do not parse it.
  • Return null or omit the next cursor at the end rather than an empty-result convention, so the termination condition is explicit.

Industry example

Every large API that has changed its pagination internals has done so behind an opaque token — the pattern is standard in the cursor-based pagination of major platform APIs, and the GraphQL Relay connection specification formalises it by defining cursors as opaque strings. The counter-example is equally instructive: APIs that shipped visible offset or timestamp cursors in production find that changing the sort order becomes a versioned, multi-quarter migration, because callers depend on the encoding rather than on the contract.

Failure scenarios

  • Silent partial results after a sort or filter change, producing an incomplete export that nobody questions.
  • Client-constructed cursors that work until the encoding changes, at which point your internal refactor is their outage.
  • Infinite or immediately-terminating loops in clients that page until an empty result, when the predicate returns nonsense.
  • Cursors stored for weeks by sync jobs, resumed long after the underlying result set stopped existing.
  • Sensitive data leaking through a decoded cursor in a support ticket or a browser URL.

Trade-offs

Opacity costs debuggability. Support and engineering can no longer read a cursor to see where a client was, so you need a way to decode one internally — a support tool, or a correlation id logged alongside the token. It also costs a little size and CPU for signing. In exchange you keep the ability to change the query shape, the shard layout and the tie-break column without a version bump, which over an API's life is worth considerably more than reading a token by eye.

When not to use it

For a cursor whose life is one user's scroll session, the machinery is disproportionate: a TTL and an explicit expiry error cover the realistic failure, which is a slightly odd page and a refresh. The full contract-version-and-signature treatment earns its cost when cursors are stored by machines — export jobs, sync clients, partner integrations — because those callers resume weeks later, never look at the result, and are the ones a silent partial answer actually harms.

Interview question

Q: You change your list endpoint's default sort. A partner resumes with a cursor issued three weeks earlier. Tell me what happens in a typical implementation, and what you would have built.

What a strong answer covers: that the rebuilt predicate is valid and meaningless, so the endpoint returns 200 with duplicates and gaps rather than an error; that an export consumer produces a silently incomplete file; the contract version inside the cursor with an explicit cursor_invalidated error; a TTL with an explicit expiry error; integrity protection so cursors cannot be handcrafted; and the observation that changing a default sort is itself a breaking change to the pagination contract.

Quick check

Quiz: Why must a cursor be opaque? So the server can change what it encodes — sort key, tie-break, shard routing — without breaking clients, because a legible cursor will be parsed and constructed by someone and becomes a public interface.

Flashcard: What does a cursor need besides the position? A contract version covering sort and filters, a TTL, and integrity protection — so a stale or handcrafted cursor produces an explicit error instead of a valid-looking partial result.