skip to content

You are specifying the cursor token for a public list API. What should the token encode, how long should it stay valid, and what should the endpoint do when a client sends a cursor whose anchor record has since been deleted, or sends it alongside a different sort or filter?

level: seniorimportance: should knowfreq 42%

answer

  1. encode sort values + query fingerprint + version, then sign
  2. sort values > row id: survives a deleted anchor
  3. fingerprint mismatch → 400 cursor_query_mismatch
  4. expired cursor must error, never return an empty page
  5. never trust scope from inside the token

basics

~20 s

Encode the sort keys of the anchor row plus a fingerprint of the sort and filters, and sign it. Keep it valid as long as practical and document any expiry. A deleted anchor should still work if the token carries sort values rather than just an id. Mismatched sort or filters must be rejected with 400, not silently applied.

solid answer

~60 s

**Contents.** The anchor's sort-key values (all of them, including the unique tiebreaker), a fingerprint of the sort order and the filter set, a direction flag, and a version byte so the format can change. Serialize, then sign or MAC it so tampering is detectable, and document it as opaque. **Deleted anchor.** If the cursor stores only a record id, deleting that record destroys the position and you are forced to error or restart. If it stores the anchor's *sort values*, the position survives — "rows ordered after (created_at, id)" is well-defined whether or not that row still exists. This is the single design choice that makes cursors robust. **Mismatched query.** A cursor is a position in one specific ordering over one specific filtered set. Applying it to a different sort or filter yields an arbitrary slice with duplicates and gaps, so compare the fingerprint and reject with `400` naming the conflict. **Expiry.** Prefer none. If your implementation forces one (a materialized snapshot, a server-side scroll context), document the TTL and return a distinct error code so clients can restart rather than silently truncating an export.

code

json · 6 lines
json
{
  "v": 1,
  "dir": "after",
  "k": ["2024-01-08T10:12:03.221Z", "o_9f3"],
  "qf": "7b41c0a9"
}

go deeper

for a junior

Know that the cursor is opaque, encodes where to resume, and must not be constructed by the client.

for a middle

Explain sort-values-versus-id, why a changed sort or filter invalidates a cursor, and the need for an explicit end-of-iteration signal.

for a senior

Own the whole lifecycle: signing, versioning, fingerprint mismatch errors, expiry semantics, key rotation, and the export-truncation failure mode.

for a principal

Standardize one cursor format and error vocabulary across services, decide stateless-versus-stateful cursors against durability requirements, and define how clients resume long-running iterations safely.

## What has to be inside the token A cursor answers one question: *where in this ordering do I resume?* To answer it without server-side state, the token must carry everything needed to reconstruct the boundary predicate. 1. **The anchor's sort-key values.** Every key in the sort, in order, including the unique tiebreaker. For `sort=-created_at` with an id tiebreaker, that is `(created_at, id)` of the last row returned. 2. **A fingerprint of the query.** A hash of the normalized sort specification and filter set. Without it you cannot detect a client mixing cursors between queries. 3. **Direction.** Whether this token means "after" or "before", if you support backwards paging. 4. **A format version.** One byte that lets you change everything else later and still recognize old tokens well enough to return a clear error. 5. **Optionally, page size** — usually better left as a separate parameter so clients may change it mid-iteration. Serialize compactly (base64url of a small structure), then **sign** with a server key or attach a MAC. Signing is not primarily about secrecy; it is about rejecting hand-crafted tokens. An unauthenticated cursor is client-controlled input that flows into your query boundary, and if a caller can craft `(created_at, id)` pairs, they can probe positions or, in a badly built implementation, escape their authorization scope. If the anchor values are themselves sensitive, encrypt rather than merely sign. Separately: do **not** put the caller's identity or tenant scope in the cursor and then trust it. Authorization is re-derived from the request's credentials every time; the cursor only carries position. A cursor that carries scope is a cursor that can be replayed by another caller. ## Why sort values beat record ids Stripe's `starting_after=obj_id` is friendly and readable, but it means the server must look the anchor record up on every request, and if the record is gone, the position is gone. That is a live failure mode for collections with deletion or expiry — the client's overnight export dies at 3 a.m. because the anchor row was deleted. Storing the sort values instead makes the boundary a pure predicate: return rows ordered after `(2024-01-08T10:12:03Z, o_9f3)`. That predicate is well-defined whether or not `o_9f3` still exists, needs no extra lookup, and works identically for any sort. It is the design to argue for, while acknowledging why id-based cursors exist (debuggability, and clients can construct a starting point from an object they already have). ## The mismatch rule A cursor names a position **within one ordering over one filtered set**. Change the sort and the position is meaningless; change the filter and the set membership changes, so "after this anchor" selects a different, arbitrary slice. Clients do this constantly — a UI keeps the cursor in state and the user toggles a filter checkbox. Three possible behaviours: reject with `400` (best — the fingerprint makes it detectable, and the error tells the client to restart from the first page); ignore the client's new sort/filter and honour the cursor's (defensible, confusing); silently apply the new query to the old anchor (never — it returns wrong data that looks fine). Emit a distinct machine-readable error code, e.g. `cursor_query_mismatch`, so client code can respond by restarting iteration instead of surfacing a generic 400 to a user. ## Validity and expiry A stateless cursor built from sort values has no intrinsic expiry: the predicate is valid forever. That is a feature — a client can resume an interrupted export a day later. Expiry appears when the implementation holds server-side state (a database cursor, a search engine's scroll context, a materialized snapshot). Then: document the TTL, return a distinct error (`cursor_expired`) rather than an empty page, and make the client's correct response obvious — restart, optionally with a time-bounded filter so it can skip what it already processed. The worst outcome is an expired cursor that yields an empty page, because the client's "loop until no next link" terminates and reports success on a partial export. Signing keys need rotation with an overlap window, or every in-flight cursor breaks at rotation time. And a format change should bump the version byte so old tokens produce `cursor_invalid`, not a misparse. ## Operational notes Cursors end up in logs and in URLs. Keep them free of PII, or encrypt. If a cursor is long, it can push URLs toward practical length limits when combined with many filters — another reason to keep filters out of the token body and only fingerprint them. Finally, define what happens on the very first request (no cursor) and on the last page (no `next_cursor`, `has_more: false`) so that iteration has clean start and stop conditions that never depend on page fullness. ## Compressed answer "Sort values plus a sort/filter fingerprint plus a version, base64url'd and signed, documented as opaque. Sort values rather than a row id, so a deleted anchor doesn't destroy the position. Fingerprint mismatch is a 400 with a specific code, never a silent reinterpretation. No expiry if it's stateless; if the implementation forces one, document the TTL and fail loudly with `cursor_expired` so clients restart instead of silently truncating."

  • Why sign the cursor if it contains no secrets?
    Because it is client-supplied input that determines the query boundary. A signature lets the server reject hand-crafted or mutated tokens outright instead of executing an arbitrary boundary predicate, and it enforces the documented opacity — clients cannot fabricate positions, so you keep the freedom to change the format. If the anchor values themselves are sensitive, encrypt rather than only sign.
  • A client's cursor expires mid-export. What is the right server behaviour and the right client behaviour?
    The server must return a distinct error such as `cursor_expired`, never an empty page or a silently restarted first page, because an empty page makes the client believe iteration completed. The client should restart iteration, ideally narrowing with a filter on the sort key — for example resuming from the timestamp of the last record it successfully processed — so it does not reprocess everything.

saying these in an interview costs you the question

  • Encoding only a record id, then having no answer for a deleted anchor row
  • Accepting a cursor with a different sort or filter and applying it anyway
  • Returning an empty page for an expired or invalid cursor instead of a distinct error
  • Trusting tenant or user scope carried inside the cursor instead of re-deriving it from credentials
  • Rotating the cursor signing key without an overlap window, invalidating every in-flight iteration

context