GraphQL / Intermediate: policy, sources and clients / Lesson 8
Cursor pagination and stable ordering
Design bounded lists with deterministic order, explicit cursor scope and meaningful continuation metadata.
On this page
Lesson goals
- Explain cursor and offset tradeoffs without claiming snapshot guarantees.
- Test a small keyset page with a tie-breaking identifier.
Treat pagination as a correctness contract
A list field should state its maximum size, sort order, filtering scope and continuation behavior. Offset pagination is simple for small stable collections, but inserts and deletes can shift positions between requests. Cursor pagination identifies a continuation boundary under a declared ordering rather than relying solely on a row count. Neither approach automatically gives a snapshot of a changing dataset. Decide whether the user needs a live feed, repeatable report or best-effort browsing; those requirements determine whether an additional snapshot or revision mechanism is needed.
Expose edges and continuation separately
A connection-style schema can put cursor metadata on edges and continuation state in pageInfo. It is a common convention rather than a compulsory shape for every GraphQL list. The example assumes the server caps first to an approved range and defines after as an opaque cursor. Requesting first: 2 must not allow unlimited related lists inside each returned node. A totalCount field, if added, needs its own cost and authorization analysis; computing an exact count over a large filtered source can dominate an otherwise small page.
query Browse($after: String) {
courses(first: 2, after: $after) {
edges { cursor node { id title } }
pageInfo { endCursor hasNextPage }
}
}
The client stores endCursor for the same filter/order context and continues only if hasNextPage is true under the service contract.
Choose a total order and scope the cursor
Sorting only by a nonunique timestamp is insufficient because multiple records can share it. Add a stable unique tie-breaker such as id and encode the continuation boundary for both values. The cursor should belong to its filter, tenant and order version; accepting a cursor from another scope can skip records or expose inappropriate metadata. Encoding with base64 is not encryption or authentication. Validate decoded structure and bounds, and use an integrity mechanism when the application needs to reject tampering rather than merely tolerate malformed input.
Model the boundary and one extra item
The local example sorts synthetic tuples by timestamp then identifier. To determine hasNextPage it reads at most page size plus one from the already in-memory eligible rows, returns two, and treats the third as evidence of continuation. A real data adapter should apply equivalent bounds at the source rather than fetching an entire table first. The comparison is strictly greater than the previous boundary, avoiding a repeated boundary item. This model tests arithmetic and ordering only; it does not model concurrent database updates or transaction isolation.
const rows = [[10,"c1"],[10,"c2"],[11,"c3"],[12,"c4"]];
const after = [10,"c1"];
const eligible = rows.filter(([t,id]) =>
t > after[0] || (t === after[0] && id > after[1]));
const size = 2;
const window = eligible.slice(0, size + 1);
const page = window.slice(0, size);
console.assert(JSON.stringify(page) === JSON.stringify([[10,"c2"],[11,"c3"]]));
console.assert(window.length > size);
The page contains c2 and c3, and the extra c4 proves continuation for this fixed fixture. No source pagination was executed.
Document behavior under insert and deletion
A cursor does not freeze a collection. A newly inserted record before the boundary may never appear in a forward browsing session, and a deleted item may shorten a page. If users export an auditable report, use a data-source snapshot or a stable export job rather than promising repeatability from cursors alone. Test empty results, equal sort keys, final pages, changed filters and invalid cursors. Keep page size limits independent of client convenience and account for nested fanout when assigning operation budgets.
Common errors and fixes
Records repeat across equal timestamps
- Why it happens
- The order omitted a unique tie-breaker.
- How to fix it
- Order and continue by timestamp plus stable ID.
- Prevent it next time
- Test several records sharing one sort value.
A cursor is reused with a different filter
- Why it happens
- Its scope was not validated.
- How to fix it
- Reject incompatible cursors under a documented error policy.
- Prevent it next time
- Version cursor format and bind filter/order context.
A tiny first value still creates huge work
- Why it happens
- Nested list fanout and count cost were ignored.
- How to fix it
- Bound nested fields and apply realistic complexity controls.
- Prevent it next time
- Review expanded operations with actual argument bounds.
Check your understanding
1. Why does ordering by updatedAt alone risk skipping items?
Show worked solution
Several items may share the boundary timestamp. A strict greater-than timestamp filter skips the remaining equal items, while greater-or-equal can repeat earlier items. Add a stable unique tie-breaker and compare the full ordered tuple. Update the cursor and source ordering together, then verify a fixture with multiple equal timestamps and a page boundary between them.
2. Does a cursor make a multi-page export repeatable under concurrent edits?
Show worked solution
No. It records a continuation boundary under the current contract, not necessarily a frozen dataset. Concurrent changes can move records or alter eligibility. A repeatable report needs a declared source snapshot, revision or export process with its own retention and completion policy. State those guarantees explicitly rather than inferring them from the presence of endCursor.
Key takeaways
- A cursor needs stable order, validated scope and bounded fanout.
- Continuation and snapshot guarantees are different promises.