Designing Cursor Pagination in GraphQL: Trust Boundaries, Sort Contracts, and Failure Modes
Cursor pagination in GraphQL only works if you treat cursors as untrusted input, commit to an immutable sort contract, and monitor the specific ways keyset pagination fails.
06 Nov 2025, 12:09 UTC

The problem this design solves
Offset pagination (skip: 200, take: 20) breaks under concurrent writes: if a row is inserted before your current page, the next page repeats a record you already saw, and deletions cause silent skips. Cursor pagination replaces the numeric offset with a server-generated opaque token that encodes "where you stopped." Because the cursor points at a specific sort position rather than a count, inserts and deletes elsewhere in the table do not shift the client's view.
The useful takeaway: cursor pagination is not just a different argument shape. It moves state onto the client, which means the server must treat that state as untrusted input, the schema must commit to a stable sort contract, and operations must watch for the specific ways cursors fail — stale tokens, unindexed sort keys, and empty-page loops.
Requirements
- Clients can page forward (and optionally backward) through a list without duplicates or gaps, even while other clients mutate the underlying data.
- Page size is bounded by the server so no client can request an unbounded result set.
- Cursors cannot be forged or tampered with to read data outside the caller's authorization scope.
- The design survives schema evolution: a cursor issued by last month's deployment must fail safely, not return wrong data.
The smallest suitable design
Follow the Relay connection convention, because it is the shape most GraphQL client tooling already understands:
type Query {
orders(first: Int, after: String, last: Int, before: String): OrderConnection!
}
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
totalCount: Int
}
type OrderEdge {
node: Order!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}Internally, a cursor encodes the sort-key value and primary key of the last returned row. For an orders list sorted by creation time, the cursor payload is conceptually {"createdAt": "2026-10-01T14:22:00Z", "id": "ord_9182"}, serialized and base64-encoded. The server resolves the next page with a keyset predicate instead of an offset:
-- Conceptual SQL behind the resolver
SELECT * FROM orders
WHERE (created_at, id) < ($1, $2) -- values decoded from the cursor
ORDER BY created_at DESC, id DESC
LIMIT $3 + 1; -- fetch one extra row to compute hasNextPageThe + 1 trick is how you compute hasNextPage without a separate count query: if you asked for 20 and got 21, another page exists. The primary key in the predicate is the tiebreaker that makes ordering deterministic when two rows share the same sort-key value.
The sort contract is part of the schema
Cursor pagination only works if the sort is total and immutable. "Total" means every row has a well-defined position — hence the primary-key tiebreaker. "Immutable" means the sort fields do not change while a client pages. If you sort by updatedAt and a background job touches rows, records can jump positions mid-pagination and the client sees duplicates or misses rows entirely.
Document the sort order in the schema description ("Sorted by createdAt descending, id descending") and treat changing it as a breaking change. If a field must be sortable by something mutable, offer it as a separate field with its own connection and accept the consistency trade-off explicitly, or snapshot the sort keys at pagination start.
Trust boundary: the cursor is untrusted input
Once the client holds the cursor, it is attacker-controlled data. Three rules follow:
- Validate structure. Decode, parse, and schema-check the cursor before using any value from it. A malformed cursor returns a clean validation error, never a database error.
- Enforce integrity. Sign the payload with an HMAC (server-side secret) or encrypt it, so a client cannot hand-craft a cursor pointing at an arbitrary sort position. Without this, a cursor is effectively a hand-written WHERE clause.
- Re-check authorization. The cursor carries position, not permission. The resolver must still apply the caller's tenant/ownership filters on every page. A cursor captured from one user's session must not page another user's data — include the tenant identifier inside the signed payload and verify it matches the authenticated principal.
Also cap first/last (for example, reject values above 100) and apply a global query cost limit, since pagination arguments alone do not stop expensive nested selections.
Operational checks
Three things are worth monitoring in production:
- Page latency by cursor depth. Keyset pagination should be O(page size) regardless of depth — but only with a composite index matching the sort, e.g.
(created_at DESC, id DESC). Without it, the database falls back to scanning and sorting, and latency grows with table size. Verify withEXPLAINthat the resolver's query uses the index. - Cursor decode/validation failure rate. A sudden rise signals a client bug, a bot probing cursors, or a botched deployment that changed the cursor format.
- Empty-page and loop indicators. Clients requesting many consecutive pages that return zero edges usually mean a client-side loop mishandling
hasNextPage, or a server bug where the cursor predicate matches nothing.
Failure modes and guards
- Stale cursors after schema evolution. If the cursor payload format or the sort contract changes, old cursors decode into nonsense. Guard: embed a version field in the payload (
{"v": 2, ...}) and reject unknown versions with a clear error telling clients to restart pagination. - Non-deterministic ordering. Sorting on a non-unique column without a tiebreaker returns rows in arbitrary order within ties, so pages overlap. Guard: always append the primary key to
ORDER BYand to the cursor predicate. - Infinite loops on empty pages. If the server returns
hasNextPage: truewith an empty edge list (a bug in the+1logic or in cursor advancement), naive clients loop forever. Guard: clients should stop when a page returns zero edges regardless ofhasNextPage; server tests should asserthasNextPageis false when the edge list is empty. - Framework migration mismatch. Base64 cursor content is not standardized across GraphQL frameworks; one may encode an offset, another a keyset. Cursors are not portable — treat a framework switch as a breaking change and version the cursor format accordingly.
Verifying the implementation
Three practical checks cover most of the risk:
- Continuity test. Script sequential queries threading
endCursorintoafter, collecting all IDs, and assert the union is contiguous, non-overlapping, and equal in size to a known fixture count. Run it against a staging environment with a fixed dataset. - Concurrency test. While the script pages, insert and delete rows ahead of and behind the cursor position. The collected set should match the expected snapshot semantics (typically: rows present at their sort position when reached) with no duplicates.
- Tamper test. Base64-decode a cursor, modify a value, re-encode, and submit. The server must reject it. Also swap a cursor between two users with different tenants and confirm no cross-tenant data leaks.
When this design changes
Keyset cursors are the right default for large, frequently mutated datasets. Reconsider when: the dataset is small and static (offset pagination is simpler and lets clients jump to page N); you need random access to arbitrary pages (keyset cursors are inherently sequential — you would need a hybrid that encodes an offset in the signed cursor); or you need stable pagination over frequently re-sorted data, in which case snapshot-based pagination (materializing the full ordered ID list at first request and paging through the snapshot) trades storage for consistency.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.