Pagination the Relay way
Cursors over offsets, and why the connection shape looks odd.
Open this lesson in the learning hubKey points
- Offset pagination breaks on changing data: insert a row on page 1 and an item shifts onto page 2, so the reader sees it twice or not at all.
- Cursor pagination encodes a position instead. "Give me 10 after this cursor" is stable regardless of inserts before it.
- The Relay connection shape -
edges,node,cursor,pageInfo- looks verbose, but the extra layer is what gives edges somewhere to carry per-relationship data. - The cursor must be opaque. Base64 of a sort key is fine; exposing a raw database id invites clients to construct their own, and then you can never change the scheme.
- Cursors must be stable under the sort, so include a tie-breaker. Paging by
created_atalone will skip or repeat rows sharing a timestamp. totalCountis optional for a reason: it usually needs a second count query over the whole set. Expose it only where the client genuinely needs it.
Example
# The connection shape.
type Query {
orders(first: Int, after: String, last: Int, before: String): OrderConnection!
}
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
totalCount: Int # optional - it costs a second query
}
type OrderEdge {
node: Order!
cursor: String! # opaque
addedAt: DateTime # per-RELATIONSHIP data lives here, not on the node
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
---
# Querying it:
query Orders($after: String) {
orders(first: 20, after: $after) {
edges { cursor node { id total createdAt } }
pageInfo { hasNextPage endCursor }
}
}
# next page: pass pageInfo.endCursor as $after
---
-- The SQL behind a cursor page. Note the TIE-BREAKER.
-- cursor decodes to (created_at, id)
SELECT * FROM orders
WHERE (created_at, id) < (:cursorCreatedAt, :cursorId)
ORDER BY created_at DESC, id DESC
LIMIT 21; -- fetch one extra to compute hasNextPage
-- Without the id tie-breaker, rows sharing a timestamp are skipped or
-- repeated at every page boundary - and it only shows up under load.
---
# Cursor encoding - opaque, and versioned so you can change it later:
# raw "v1:2026-08-03T10:00:00Z:12345"
# cursor base64 -> "djE6MjAyNi0wOC0wM1QxMDowMDowMFo6MTIzNDU="
#
# Clients must treat it as a token. Never document its contents, or it
# becomes an API you cannot change.
Cursors are stable where offsets are not - encode a sort key plus a tie-breaker, and keep the cursor opaque.
This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the GraphQL Course course, and every lesson in it is listed on the GraphQL Course contents page.