What does a GraphQL connection cursor have to encode for the next page to resume exactly where the last edge ended?
answer
- It names a place, not a row
- Whatever the sort is, mirror it
- One point, never a tie
- Rebuilt into a tuple comparison, not a skip
basics
~20 sThat row's value for every field the connection sorts by, plus a unique tie-breaker. The server turns those values back into a keyset comparison — rows strictly after this position in this order — instead of skipping a count of rows.
solid answer
~50 sCursor paging is keyset paging, so the cursor's job is to name a **position in an ordering**. That means it must carry, for the row it hangs off, the value of every field in the sort key plus a value unique across the set, so the position is one point and not a tie. The server decodes those values and builds a tuple comparison — for a filings connection sorted `filedAt` descending then `id`, that is `(filed_at, id) < ('2026-04-11T09:14:07Z', 'f_90341')` — which walks straight into the composite index and costs the same at page 400 as at page 2. Two extras earn their bytes by convention: a format version, and a short hash of the ordering and filter arguments so a replay under different arguments is detectable. The offset, and the row itself, do not belong.
code
graphql · 8 linesquery MatterFilings($matterId: ID!, $after: String) {
matter(id: $matterId) {
filings(orderBy: FILED_AT_DESC, first: 25, after: $after) {
edges { cursor node { id caption filedAt } }
pageInfo { hasNextPage endCursor }
}
}
}go deeper
Know that the cursor marks where the previous page stopped and that the server uses it to continue, rather than counting rows and skipping them. Being able to say "it holds enough to find that spot again" is enough at this level.
Be ready to write the predicate. An interviewer expects sort values plus a unique tie-breaker, the tuple comparison rather than a conjunction of per-column comparisons, and the limit of page-size-plus-one used to answer whether more pages exist.
Show that you treat the payload as a versioned wire format with an owner. Explain why a self-contained cursor is more robust than a row reference, and how you would evolve a connection's ordering without silently mis-resuming cursors already in the wild.
Own the tradeoff between what the cursor carries and what the server has to look up: bytes on every edge versus round trips and fragility per page. Decide house rules — what may never go in a cursor, how format versions are retired, and who is allowed to change a connection's ordering.
## The job a cursor has to do Paging a sorted list by cursor is **keyset paging**: instead of "skip 250 rows then read 25", the server asks "read the first 25 rows that come strictly after *this position* in *this order*". The cursor's entire purpose is to name that position. So the contents follow mechanically from the ordering: the cursor must carry, for the row it hangs off, **the value of every field the ordering sorts by, plus a value that is unique across the whole set** so the position is a single point and not a tie. Everything else in a cursor is optional; those values are not. ## Worked shape Take a legal case-file graph. `Filing` is a 37-field type, and a matter's filings are exposed as a connection sorted newest-first: - ordering: `filedAt` descending, then `id` descending as the tie-breaker - 18,437 filings on the matter, page size 25 The 25th edge's cursor decodes to `{"v":1,"filedAt":"2026-04-11T09:14:07Z","id":"f_90341"}`. On the next request the server does not "skip 25" — it builds the comparison ``` WHERE (filed_at, id) < ('2026-04-11T09:14:07Z', 'f_90341') ORDER BY filed_at DESC, id DESC LIMIT 26 ``` and the store walks straight into the middle of the composite index. The work is proportional to the page, not to how deep into the list you are. Two details fall out of that shape. The comparison is a **tuple** comparison, not `filed_at < X AND id < Y` — the latter drops every row that shares the boundary timestamp but has a larger id, which is exactly the ties the tie-breaker exists to resolve. And the limit is one more than the page size, because reading a 26th row is the cheapest way to answer whether a further page exists. ## Why the tie-breaker is not optional If two filings were both stamped `2026-04-11T09:14:07Z` and the cursor carried only the timestamp, the resume predicate `filed_at < '...'` skips **both** of them, and `filed_at <= '...'` returns both again. There is no third option: with a non-unique sort key, a cursor cannot name a row. The unique field is what turns "somewhere around here" into "exactly here". (The *design* of a total order — choosing the tie-breaker, giving a connection a deterministic default order — is its own subject; the point here is that whatever that order is, the cursor's payload must mirror it field for field.) ## The tempting shortcut, and why it is worse A very common first design stores only the row's id and, on the next request, looks the row up to recover its sort values. It is smaller and it feels tidy. It has two real costs. **An extra round trip per page.** Every page now begins with a point lookup before the range scan can even start. **The position evaporates when the row does.** If that filing was deleted — or edited so its `filedAt` moved — there is nothing to look up, and the server has to choose between erroring and silently restarting. A **self-contained** cursor has no such failure: the values it carries still describe a valid boundary in the ordering whether or not any row currently sits there. That is the deeper principle worth saying out loud in an interview: *a cursor is a position in an ordering, not a pointer to a row.* Encoding the sort values rather than a reference is what makes that true. ## What else earns its bytes Beyond sort values and the tie-breaker, two additions pay for themselves and both are conventions, not specified: - **A format version.** Clients hold cursors across deploys. A version tag lets the server recognise a payload from the previous shape and reject it with a clear error instead of mis-resuming. - **A fingerprint of the ordering and filter arguments** — a short hash, not the arguments verbatim. It lets the server detect a cursor being replayed against a different `orderBy` or filter, where the carried values no longer name a position in the requested order. What does **not** belong: the whole row, the offset, or anything the caller is not entitled to see. Size matters more than it looks, because a connection mints one cursor per edge rather than one per page — the cost is paid 25 times, not once. ## The change that breaks held cursors Adding a field to a connection's ordering is quietly a breaking change for cursors already in clients' hands: those payloads are missing a component of the new position and cannot be resolved into a correct predicate. Either keep the old order resolvable, or version the payload and fail loudly. Silently treating an old cursor as if the missing field were null is how an export ends up with a hole in it.
- What breaks if the cursor stores only the row id and the server looks that row up to recover its sort values?Two things. Every page now begins with an extra point lookup before the range scan can start. Worse, the resume point disappears when the anchor row is deleted, or moves when someone edits the field it sorts by — and the server is forced to choose between erroring and silently restarting. A self-contained cursor has neither problem, because the values it carries still describe a valid boundary whether or not a row sits there.
- You add a second field to an existing connection's ordering. What happens to cursors clients already hold?They stop naming a position: the payload is missing a component of the new sort key, so no correct predicate can be built from it. Treating the missing field as null quietly produces holes or repeats. The safe handling is a version tag in the payload so the server recognises the old shape and rejects it with an explicit error, or keeping the previous ordering resolvable during a transition.
- How big should a cursor payload be allowed to get?Smaller than instinct suggests, because a connection mints one cursor per edge, not one per page. Payload grows with the number of sort fields, and base64 adds about a third on top, so a 25-edge page of 120-byte cursors costs roughly 3 KB of response. Keep it to sort values, the tie-breaker, a version and a short fingerprint — hash the filter set rather than embedding it.
A cursor is not a bookmark clipped to a page; it is a written note saying "I stopped just after the 11 April entry, the one numbered f_90341". The note still works even if that entry is later torn out.
saying these in an interview costs you the question
- The cursor stores the row's offset in the result set
- A unique id alone is enough to resume a sorted page
- Every edge on a page shares the same cursor
- Sort fields and cursor contents can be chosen independently
- Adding a sort field is safe for cursors clients already hold
- The whole row goes in so the next page need not re-read it