Your API issues opaque cursors for keyset pagination. Three weeks after a client stored one, you change the default sort from created_at to relevance_score. The client resumes with the old cursor. What happens, and what should happen?
Show the full answer Hide the answer
What happens in the naive implementation
The cursor encodes the last-seen created_at and id. The new query orders by relevance_score. The predicate rebuilt from the cursor is now a filter on created_at applied to a relevance-ordered scan, so one of two things follows:
- The cursor fails to decode into the new predicate and the endpoint returns a 500, which is the good outcome because someone notices.
- The predicate is syntactically valid and semantically meaningless. The endpoint returns an arbitrary subset of the collection, with duplicates and gaps, and returns it with a 200.
Where it amplifies
A client paging in a loop until it receives an empty page may never receive one, or may receive one immediately. An export job silently produces an incomplete file that is indistinguishable from a complete one, and that file is then loaded into somebody's warehouse and reconciled against weeks later, if ever.
Nothing in this path errors. The failure is a quiet wrong answer, which is the most expensive kind an API can return.
What should happen
- The cursor carries a version of the contract it was issued under — the sort key, the filter set and a schema version. On resume, a mismatch returns an explicit, machine-readable error: 400 or 409 with a code such as
cursor_invalidated, naming the remedy, which is to restart pagination from the beginning. - Cursors have a TTL. Three weeks is far outside any reasonable window; a cursor is a position in a result set that has long since ceased to exist. A day is generous for most collections, and the expiry must be an explicit error rather than a silent reset to page one.
- Cursors are opaque and integrity-protected, not for secrecy but for freedom. An opaque, signed cursor cannot be constructed by a client, so you retain the ability to change what it encodes. A cursor that is visibly a base64-encoded timestamp will be parsed and rebuilt by somebody, and from that moment it is a public interface you did not intend to publish.
- The sort change itself is a breaking change to the pagination contract and should be introduced as a new parameter with the old default retained, not as a change of default.
What would have to be true for it to self-heal
Nothing self-heals here without the version check, because there is no signal. The client is satisfied, the server is satisfied, and the only evidence is a row count that nobody compares. This is the argument for making the check mandatory rather than optional: a partial result with no error produces no bug report, ever.
When not to build all of this
For a cursor whose lifetime is one user's scroll session, the worst case is an odd-looking page and a refresh, and a TTL plus an explicit expiry error is proportionate on its own. The full contract-version machinery earns its cost when cursors are stored by machines — export jobs, sync clients, partner integrations — because those are the callers that will resume weeks later and never look at the result.