Stable List Traversal in GraphQL: Adopting Relay Cursor Connections
Offset pagination skips items and slows deep pages. Learn how Relay Connections use opaque cursors and deterministic ordering for stable GraphQL list traversal.
03 Aug 2026, 13:42 UTC

A user pages through a product catalog, and halfway through, an admin publishes a new item. When the user loads the next page, the last row from the previous page appears again, and the first row of the new page is missing. Separately, a report that fetches page 100 times out because the database is scanning and discarding 990 rows to return the last 10. Both symptoms trace back to the same design choice: numeric offset pagination.
The Relay Connection specification replaces numeric offsets with opaque cursors that encode a stable position in a deterministic total order. Instead of asking for "rows 21 through 30," the client asks for "the next 10 items after this cursor." The server decodes the cursor into a sort key and resumes exactly where the previous page ended.
Why Offset Pagination Breaks
Offset pagination fails in two distinct ways. The first is consistency. An OFFSET 20 LIMIT 10 query assumes the underlying result set is immutable between requests. If a row is inserted near the top of the sort order, every subsequent offset window shifts forward, causing the client to skip an item. If a row is deleted, the window shifts backward, causing a duplicate.
The second failure is performance. Most databases implement OFFSET by producing and discarding the preceding rows before returning the requested page. An OFFSET 10000 query on a large table forces the engine to sort and skip ten thousand records, so latency grows linearly with page depth even when the final result set is small.
Anatomy of a Connection
A Relay Connection wraps a list in a predictable envelope. The edges array contains objects with a node—the actual entity—and a cursor, an opaque string that marks that node's position. A pageInfo object reports whether hasNextPage and hasPreviousPage are available, along with startCursor and endCursor for convenience.
Pagination arguments map to the four boundaries: first and after move forward; last and before move backward. The cursor itself should encode a stable, unique sort key—often a base64 string combining the sort column and a primary key—so the server can resume without exposing internal identifiers or inviting client-side tampering.
A Worked Example
Consider a products query sorted by createdAt descending. A schema might define the connection like this:
type Product {
id: ID!
name: String!
createdAt: String!
}
type ProductEdge {
node: Product!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
}
type Query {
products(first: Int, after: String, last: Int, before: String): ProductConnection!
}
A client requests the first page:
query {
products(first: 2) {
edges {
node { id name createdAt }
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
The resolver decodes the after cursor into its component sort values. Because createdAt alone is not guaranteed unique, a deterministic tiebreaker such as id is mandatory. Without it, two rows sharing the same timestamp can appear on multiple pages or vanish entirely. The cursor might encode createdAt|id as base64, and the resolver translates it into a database predicate:
-- Cursor predicate (syntax varies by database)
WHERE created_at < :cursor_created_at
OR (created_at = :cursor_created_at AND id < :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT :first + 1
Fetching one extra row lets the server set hasNextPage accurately without a separate count query. The endCursor returned to the client is the cursor of the last node in the current page, not the extra row.
Trade-offs and Limitations
The most significant limitation is the loss of random access. A client cannot jump directly to page 50 because there is no numeric offset to translate. If your product requires jump-to-page navigation, you must either expose a separate offset-based escape hatch for that specific use case or maintain a search index that supports arbitrary positioning.
Bidirectional pagination adds further complexity. Supporting last and before requires reversing the sort order and carefully validating cursors that point backward. Many teams ship forward-only pagination initially and add backward traversal once the forward path is stable.
Cursor formats are not standardized across implementations. Changing the encoding later can break existing clients, so treat cursors as opaque and version them if the underlying sort key changes. Deep pagination also still stresses servers even with cursors; pair connection limits with query cost analysis or maximum first and last values to prevent abusive traversal.
Verifying Stability and Performance
To confirm stability, write an integration test in your API test suite. Request the first page with first: 2. Insert a new item with a sort value newer than the boundary item. Request the second page using the endCursor from the first response. Assert that the boundary item from page one does not reappear and that no item is skipped.
To verify performance, run EXPLAIN ANALYZE in your database's SQL console or a read-only SQL client connected to a staging environment. Compare the offset and cursor forms using placeholders for your table and sort column:
-- Offset approach: scans and discards rows
EXPLAIN ANALYZE
SELECT id, name, created_at
FROM products
ORDER BY created_at DESC
LIMIT 10 OFFSET 10000;
-- Cursor approach: seeks to the boundary
EXPLAIN ANALYZE
SELECT id, name, created_at
FROM products
WHERE created_at < '2026-10-15T12:00:00Z'
OR (created_at = '2026-10-15T12:00:00Z' AND id < 'prod_123')
ORDER BY created_at DESC, id DESC
LIMIT 11;
Note that EXPLAIN ANALYZE executes the query; avoid running it against a production primary during peak traffic. Finally, inspect your schema through a GraphQL introspection query from your playground, CLI, or CI pipeline to confirm that connection fields expose edges, pageInfo, and cursor arguments, and that node types match the underlying entity.
Cursor-based pagination is not a drop-in replacement for every list endpoint, but for any collection that mutates while users browse it, the Relay Connection pattern trades the illusion of random page access for correctness and predictable latency.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.