GraphQL Pagination: Relay-Style Connections
Understand cursor-based pagination using the Relay connection spec, why it scales better than offsets, and how to implement it cleanly in your schema.
What you'll learn
- ✓Why offset pagination breaks at scale
- ✓The Relay connection spec structure
- ✓How cursors encode position
- ✓Resolving edges, nodes, and pageInfo
- ✓Forward vs backward pagination patterns
Prerequisites
- •Familiar with HTTP and databases
What and Why
Pagination in GraphQL has a de facto standard: the Relay connection spec. It uses opaque cursors rather than numeric offsets and structures responses around edges, nodes, and a pageInfo object. Although it began as a Relay client convention, most GraphQL servers and clients today adopt it because it solves real problems that offset pagination cannot.
Offset pagination (LIMIT 20 OFFSET 100) is simple but breaks under three pressures: data shifts between requests cause duplicates or skips, deep offsets force databases to scan and discard huge row counts, and the API leaks implementation details. Cursors fix all three.
Mental Model
A connection is a window over an ordered list. The cursor is a bookmark: when you ask for the next page, you say “give me items after this bookmark”. The server decodes the cursor, jumps to that position using an indexed column, and returns the next slice plus a fresh bookmark.
The shape is intentionally verbose: edges wrap each node with its own cursor, and pageInfo tells the client whether more pages exist. This separation lets the client paginate in either direction without round-trips to discover totals.
Hands-on Example
A typical query:
query {
posts(first: 10, after: "cursor123") {
edges {
cursor
node { id title }
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
posts(first: 10, after: "X")
|
v
+-------------------+
| Connection |
| +-------------+ |
| | edges [] | | edge.cursor -> bookmark per node
| | +-------+ | |
| | | node | | |
| | +-------+ | |
| +-------------+ |
| | pageInfo | | hasNextPage, endCursor -> next request
| +-------------+ |
+-------------------+
Server-side, resolving this against a SQL table looks roughly like:
async function posts({ first, after }) {
const afterId = after ? decodeCursor(after) : null;
const rows = await db.query(
`SELECT * FROM posts
WHERE ($1::int IS NULL OR id > $1)
ORDER BY id ASC
LIMIT $2 + 1`,
[afterId, first]
);
const hasNextPage = rows.length > first;
const slice = rows.slice(0, first);
return {
edges: slice.map(r => ({ cursor: encodeCursor(r.id), node: r })),
pageInfo: {
hasNextPage,
hasPreviousPage: !!afterId,
startCursor: slice[0] && encodeCursor(slice[0].id),
endCursor: slice.at(-1) && encodeCursor(slice.at(-1).id),
},
};
}
Cursors are typically base64-encoded so clients treat them as opaque tokens, not as keys to manipulate.
Common Pitfalls
- Encoding cursors as plain integers. Clients may then build offset math, defeating the spec’s intent. Always base64 and prefix with the type.
- Forgetting to fetch
first + 1rows. Without that extra row,hasNextPagebecomes a guess. - Sorting on a non-unique column without a tiebreaker. Two rows with identical timestamps cause page boundaries to skip or repeat. Always include a secondary key like
id. - Mixing
first/afterwithlast/beforein the same request. The spec discourages this; pick a direction per call. - Returning total counts on every page. They are expensive and often inaccurate during writes. Expose them as a separate optional field only when needed.
- Letting clients request unbounded
firstvalues. Enforce a server-side maximum, typically 100.
Practical Tips
Treat the cursor format as private. Encode the sort key and an ID, sign or version it if you need to evolve the schema, and never let clients construct one manually.
When sorting by something other than ID (created_at, score), include both in the cursor: (createdAt, id) ensures stable ordering even with ties.
Add filters and sorting as separate arguments rather than baking them into the cursor. This keeps the connection contract clean and lets clients change filters without invalidating their pagination state.
For very large datasets, consider keyset pagination semantics under the hood even if the public shape is Relay. The wire format and the storage strategy are independent.
Cache connection results carefully. Apollo and Relay both have specific cache policies for connections; using their helpers (relayStylePagination, @connection) avoids subtle merge bugs.
Wrap-up
Relay-style connections look heavy at first, but they solve hard pagination problems that offsets cannot. Wrap nodes in edges, encode opaque cursors over indexed columns, and let pageInfo drive the client. Once you internalize the shape, it becomes the default pagination pattern across all your GraphQL APIs.
Related articles
- GraphQL GraphQL Error Handling Best Practices
Compare the errors array, union result types, and partial responses to design predictable, typed error handling for your GraphQL APIs and clients.
- GraphQL GraphQL Caching with Apollo Client
How Apollo Client's normalized cache works, why entity IDs matter, and the patterns for cache updates, refetches, and consistent UI after mutations.
- GraphQL GraphQL Federation: A Practical Overview
Understand Apollo Federation: subgraphs, the gateway, entity references, and when to choose federation over a monolithic GraphQL schema.
- GraphQL GraphQL N+1 and DataLoader
Why GraphQL resolvers cause N+1 query storms and how DataLoader batches and caches them away. Clear examples, real code, and the pitfalls.