Course outline

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.

About 4 min readContent reviewed
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.

graphql A bounded connection selection
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.

javascript Offline keyset window
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.

Official references