A GraphQL connection's rows change mid-paging — what happens to a cursor whose anchor row was deleted?
answer
- Ask what the cursor actually points at
- A coordinate survives what a pointer does not
- Resuming correctly is not the same as consistently
- Insertions are lost, not duplicated
- Carry the snapshot boundary in the payload
basics
~20 sNothing, if the cursor carries the row's sort values: it names a coordinate in the ordering, not the row, so the scan resumes at the same boundary and finds it empty. Only a cursor holding a row reference breaks.
solid answer
~50 sA well-built cursor encodes the anchor row's sort values, so it describes a **position in an ordering** rather than a pointer to a row. Delete the anchor and the boundary predicate `(filed_at, id) < ('2026-04-11T09:14:07Z','f_90341')` is still exactly right; the scan resumes in the same place and simply finds no row on the boundary. A cursor that stores only a row reference and looks it up does break, and that is the design flaw to name. What remains is **drift**, not breakage: rows inserted ahead of the traversal are never seen (offset paging would duplicate them instead), and a row whose sort field is edited can cross the boundary and be returned twice or never. None of this is specified — stability is entirely a server decision. Live feeds can accept drift; exports need an as-of watermark carried in the cursor.
code
pseudocode · 11 lines// Self-contained cursor: the boundary holds with or without the anchor row.
payload = decodeCursor(after) // { filedAt, id, asOf }
rows = store.select(
where = matterFilter
AND createdSeq <= payload.asOf // as-of watermark: one logical snapshot
AND tupleLessThan((filedAt, id), (payload.filedAt, payload.id)),
orderBy = [filedAt DESC, id DESC],
limit = first + 1
)
// filing f_90341 may have been deleted; the boundary is still valid.go deeper
Know that the list can change between pages and that this is normal rather than a bug. Being able to say the cursor marks a place in the ordering, so a removed row does not by itself break paging, is what is expected.
Explain the mechanics of drift: which rows a newest-first traversal misses, why cursor paging omits where offset paging duplicates, and why a cursor holding sort values survives a deletion that a cursor holding a row reference does not.
Diagnose from symptoms. An interviewer expects you to ask what the traversal is for before prescribing anything, to distinguish a broken resume from acceptable drift, and to know that a transaction cannot span requests so the snapshot boundary has to travel in the cursor.
Own the guarantee the platform offers. Decide which connections promise a consistent traversal and which are explicitly best-effort, what that costs in retention and index design, and how the guarantee is documented so downstream teams do not build reconciliation on a feed that may omit rows.
## The first thing to get right: a cursor is a position, not a pointer Most of the confusion about mid-paging mutation dissolves once the cursor's nature is stated precisely. A well-built connection cursor carries the **sort values** of the row it hung off — say `{"filedAt":"2026-04-11T09:14:07Z","id":"f_90341"}` for a legal case-file graph paging a matter's 18,437 filings newest-first. The server turns those values into a boundary predicate. That predicate is a statement about the **ordering**, not about the row: "everything strictly before this coordinate." So when a clerk deletes filing `f_90341` between page four and page five, nothing breaks. The tuple `('2026-04-11T09:14:07Z','f_90341')` is still a perfectly good coordinate; the scan resumes at exactly the same place and simply finds no row sitting on the boundary. **A deleted anchor is a non-event.** It is only a problem for the shortcut design — a cursor that stores a row reference and looks the row up to recover its sort values. Then a deletion really does destroy the resume point, and the server is forced into an unattractive choice between erroring and restarting from the head. If an interviewer's scenario says "the anchor row was deleted and paging broke", the diagnosis is *the cursor was a pointer*, and the fix is to make it self-contained rather than to add deletion handling. ## Drift is still real, and it is not the same as breakage Correct resumption does not mean a consistent traversal. Between requests the underlying set is moving, and cursor paging distributes that movement in a specific, predictable way. **Rows inserted ahead of the cursor are never seen.** In a newest-first feed, a filing docketed while the paralegal is on page four sorts *above* the boundary. The traversal is walking away from it and will not come back. Compare offset paging, where the same insertion shifts every subsequent page by one and causes a **duplicate**. Cursor paging trades duplicates for omissions. **Rows deleted ahead of the cursor are equally invisible** — they were already emitted, and the client is holding a copy of a row that no longer exists. **Rows whose sort value is edited can cross the boundary in either direction.** A filing re-stamped with an earlier `filedAt` moves from the already-read region into the unread one and is returned a second time. Re-stamped later, it jumps over the cursor and is never returned at all. This is the one case that produces *both* failure modes from a single edit, and it is the argument for sorting on an immutable field — a creation instant or a monotonic sequence — rather than on something editable. Worth saying plainly: **none of this is specified.** The connection convention describes shapes and page-info flags; it says nothing about what happens when the set changes underneath a traversal. Stability is entirely a server design decision. ## Choosing what the connection is for The senior move is refusing to answer in the abstract and asking what the traversal is. **A live feed** — a case timeline someone is scrolling — should accept drift. Nobody is harmed by a filing that appears at the top rather than in the paged tail; the newest-first ordering means new work lands where the user is looking anyway. Add client-side de-duplication by a stable node identifier and stop there. **An export, a sync job, or a reconciliation** cannot accept drift, because a skipped row is silent data loss and nobody will notice until the numbers disagree. Here you need every page to read the **same logical set**. ## Getting a snapshot across many requests The instinct — "wrap it in a transaction" — does not work: a database transaction lives inside one request, and pages are separate requests, possibly served by different instances. What does work is carrying the snapshot boundary **in the cursor itself**, as an *as-of* watermark: a commit timestamp, or the maximum sequence value at the moment the traversal began. Every page then applies that watermark as an extra filter alongside the keyset predicate, so rows created after the traversal started are excluded by construction, on every page, without any coordination between requests. That handles insertions cleanly. Deletions and updates need the underlying store to retain enough history to answer "as of then", which most operational tables do not — so in practice a watermark gives a traversal that never *skips* new work and never *duplicates* it, while updates during the run remain best-effort. Say that limitation out loud; claiming a full snapshot from a watermark alone is the overclaim an interviewer is listening for. ## The bias to encode when you must choose If you cannot make a traversal perfectly consistent, prefer the design that **duplicates rather than skips**, and give the client a stable identifier to de-duplicate on. A duplicate is a cosmetic problem the consumer can fix; an omission is invisible on both sides.
- How can a traversal read one logical snapshot when every page is a separate request?Not with a transaction — one cannot span requests, and pages may hit different instances. Instead carry the snapshot boundary in the cursor as an as-of watermark: a commit timestamp or the maximum sequence value when the traversal began. Every page applies it as an extra filter, so later insertions are excluded by construction. Deletions and updates during the run remain best-effort unless the store retains history, and you should say so rather than claiming a full snapshot.
- Which is worse for a client, a skipped row or a duplicated one?It depends on what the traversal feeds. In a scrolling feed a duplicate is cosmetic and de-duplicating on a stable node identifier removes it entirely. In an export, a sync or a reconciliation, a skipped row is silent data loss that nobody notices until totals disagree. So when a design forces the choice, prefer duplicating over skipping and let the consumer de-duplicate.
- Why does sorting on an editable field make drift worse than sorting on an immutable one?An edit to the sort field moves a row across the traversal's boundary. Re-stamped earlier, it drops from the already-read region into the unread one and is returned a second time; re-stamped later, it jumps ahead of the cursor and is never returned. A single edit can therefore cause both failure modes. Sorting on a creation instant or a monotonic sequence removes that class entirely.
Telling a colleague "carry on from just after the 11 April entry" still works if that entry has since been struck out; telling them "carry on from the page this paperclip is on" does not, once someone removes the paperclip.
saying these in an interview costs you the question
- A deleted anchor row always invalidates its cursor
- Cursor paging guarantees every row is seen exactly once
- One database transaction across the pages fixes drift
- Insertions during paging cause duplicates, as with offset paging
- The connection specification defines behaviour when rows change
- Just tell the client to restart from page one