skip to content

Why must a GraphQL cursor connection's sort order include a unique tie-breaker column?

level: juniorimportance: must knowfreq 56%

answer

  1. A cursor points at a position
  2. Two equal rows, one position
  3. Specified order is not total order
  4. Append something unique and immutable
  5. Convention, not the specification

basics

~20 s

A cursor names a position in an ordered list. If two rows compare equal on the sort key, that position is ambiguous, so the next page can repeat or skip rows. Appending a unique column makes the order total.

solid answer

~50 s

A cursor identifies a *position* in an ordered list, and a server resumes by comparing rows against the ordering values that cursor carries. That comparison has one answer only if the order is **total** — if no two distinct rows compare equal. Sorting a clinic's appointments by `scheduledAt` alone is not total: every appointment in the same slot ties, and the sequence inside that block is whatever the storage layer produced this time. Resuming from a cursor inside the block then either skips the rest of it or serves it again. The fix is to append a column that is unique and immutable per row — usually the primary key — to every order the field can produce, so `scheduledAt` becomes `(scheduledAt, id)`. Neither the GraphQL specification nor the Cursor Connections (Relay server) specification requires this; it is a convention every correct implementation follows.

code

pseudocode · 5 lines
pseudocode
# NOT total: appointments in the same slot compare equal
order = [ (scheduledAt, ASC) ]

# Total: every order the field can produce ends with a unique, immutable column
order = [ (scheduledAt, ASC), (id, ASC) ]

go deeper

for a junior

Be ready to say what a cursor points at and why two rows that compare equal make that pointer ambiguous. Knowing the one-line fix — append a unique, unchanging column such as the id to the sort — is enough at this level.

for a middle

Explain the mechanics: resumption is a comparison against the cursor's ordering values, so a tie leaves the server choosing between skipping the rest of the block and serving it again. Name the three properties a tie-breaker needs: unique, immutable, same direction.

for a senior

Show you have seen the production shape — a bulk import creating a large tied block, a paging loop that never terminates, a defect whose symptoms change with page size. Be clear that this is convention, not specification, and that the rule applies to every order the field offers.

for a principal

Own it as a contract rule rather than a per-field fix: every paged field in the graph ends its sort with a unique tie-breaker, enforced in review or by a shared paging helper, so no team rediscovers the bug. Be able to say what the rule does not cover.

## What "total" actually means An ordering over a set of rows is **total** when, for any two *distinct* rows, it says which one comes first — no two rows ever compare equal. That is a stronger property than "the order is specified". A hospital's appointment list sorted by `scheduledAt` has a perfectly well-specified order, and it is still not total: every appointment booked into the same 09:00 slot ties, and the sequence *within* that tied block is whatever the storage engine happened to produce on that particular execution. A cursor connection cares about totality because a cursor is a **position**, and a position in a list that has ties is not a single place. ## How resumption actually works, and why ties break it A client sends `after: <cursor>` and the server has to answer "which rows come strictly after that position?". It answers that by **comparison**, not by counting: it takes the ordering values the cursor carries and asks the storage layer for rows that sort after them. If the ordering is `(scheduledAt)` alone and the cursor sits on a row inside a tied block, there are only two things the server can write, and both are wrong: - Compare with **strictly greater** and every remaining row in the tied block is skipped — the client never sees them. - Compare with **greater-or-equal** and the whole tied block comes back again — the client sees rows it already has, and if the block is larger than the page size the paging loop can never advance past it. Neither failure announces itself. The response is a valid GraphQL response, the connection shape is correct, `hasNextPage` is true, and the client cheerfully asks for more. ## The tie-breaker The fix is one rule, applied to *every* order the field can produce: append a column that is **unique** and **immutable** per row, and sort by it too. The primary key is the usual choice, so `scheduledAt` becomes `(scheduledAt, id)`. Now no two rows compare equal, comparison against a cursor has exactly one answer, and the tied block has a fixed internal sequence that is the same on every execution. Three properties matter for the tie-breaker, and candidates usually name only the first: 1. **Unique across the whole result set** the field can return — not merely unique within a page. The comparison spans pages, so per-page uniqueness buys nothing. 2. **Immutable for the row's lifetime.** A tie-breaker that changes moves the row inside the order, which reintroduces the duplicate-or-skip failure by another route. 3. **Comparable in the same direction as the rest of the sort.** The tie-breaker has a direction, and it must be the same direction on the first page and on every resume, or the "after this position" comparison flips meaning halfway through. ## Specified, or convention? Worth being precise about, because interviewers probe it. The **GraphQL specification** says nothing about how a list field orders its items — it defines that a list result preserves the order the resolver produced, and stops there. The **Cursor Connections specification** (the Relay server specification) defines the connection, edge and page-info shapes and says a cursor is an opaque string identifying a position, but it does not dictate how the server determines order, and it does not state that the order must be total. So the tie-breaker rule is a **convention** — one that every correct implementation converges on because the alternative is silently wrong paging, but a convention nonetheless. Saying "the spec requires a total order" is a wrong answer. ## What it looks like when it is missing A concrete shape from a hospital appointment graph. A clinic's `appointments` connection is ordered by `scheduledAt` ascending; a data migration imports 8,400 recall appointments and stamps them all with the same timestamp, because the source system only stored a date. A client walking the connection with a page size of 50 reaches that timestamp and stops making progress: each request returns rows from inside the same tied block, `hasNextPage` stays true, and the client's accumulated array grows without a bound until it exhausts memory or someone kills the job. The bug was there from the first day the field shipped; the migration simply made the tied block big enough to notice. ## What totality does *not* fix Totality makes a position unambiguous **for a fixed set of rows under a fixed order**. It does not stop rows from being inserted or deleted while a client pages, it does not make the sort stable if the sort column itself is mutable, and it says nothing about what happens if the client switches sorts mid-walk. Those are separate concerns with separate answers. The claim totality earns you is narrow and important: given a cursor, there is exactly one row it names and exactly one set of rows that come after it.

  • Does the tie-breaker have to be unique across the whole collection, or only within one page?
    Across the whole set of rows the field can return. The comparison that resumes a page spans page boundaries — it asks which rows sort after the cursor's values across the entire collection — so uniqueness that only holds inside one page buys nothing. A primary key qualifies; a per-page row number does not.
  • If the sort key is already unique, do you still append the id?
    Strictly it is unnecessary, but appending it anyway is cheap insurance and makes one rule apply to every order the field supports. Uniqueness constraints get relaxed, columns get backfilled with duplicates, and a sort that was total on Monday quietly stops being total later. A tie-breaker on an already-unique key never changes the result.
  • What if the sort column is mutable — a clerk edits an appointment's time while a client is paging?
    Totality is not enough there. A row whose sort key changes moves within the order, so it can be seen twice or missed entirely even though no two rows ever tie. Totality guarantees a cursor names exactly one position for a fixed set of rows under a fixed order; a mutating sort key breaks the "fixed order" half of that, which is a separate problem.

A shelf mark tells you where a book sits only if no two books share it. Give two books the same mark and "start from the one after this" stops being an instruction anyone can follow.

saying these in an interview costs you the question

  • Says the GraphQL spec requires a total sort order
  • Thinks unique cursor strings make the order total
  • Believes rows come back in insertion order by default
  • Adds the tie-breaker to the cursor but not to the sort
  • Picks a mutable column, such as status, as the tie-breaker
  • Says ties are rare enough to ignore in practice

context