GraphQL Pagination
Any list in an API that can grow without limit (orders, comments, search results, audit logs) needs pagination. In GraphQL, the de facto standard is cursor-based pagination using the Relay Connection pattern: a list field returns a Connection object with edges (each holding a node and an opaque cursor) and pageInfo telling the client whether more pages exist.
The pattern looks verbose at first, but it's stable under inserts and deletes, maps cleanly to efficient keyset database queries, supports infinite scroll and "load more" naturally, and is understood out of the box by Relay and Apollo client caches. It's worth adopting from the first list field, because retrofitting pagination later is a breaking change.
TL;DR
- Offset pagination (
limit/offset) is simple but shifts when data changes and gets slow at deep offsets. - Cursor pagination (
first/after,last/before) uses an opaque pointer to "after this item". It's stable and efficient. - The Relay connection spec:
XConnection { edges { cursor node } pageInfo { hasNextPage endCursor … } }, often with a conveniencenodesfield. - Back cursors with keyset queries (
WHERE (created_at, id) < (...) ORDER BY ... LIMIT n+1), not offsets. - Make cursors opaque (base64-encoded), and cap page sizes on the server.
totalCountis convenient but can be expensive; make it optional or approximate on large tables.
Quick Example
Schema:
Client query for the next page:
Resolver using a keyset query (TypeScript):
Core Concepts
Offset vs Cursor
Offset pagination is acceptable for small, admin-style tables that need numbered pages. For feeds, timelines, and large or frequently changing data, use cursors.
The Connection Specification
The Relay Cursor Connections spec standardizes:
- Arguments:
first+afterfor forward pagination, andlast+beforefor backward. - Edges: the item (
node) plus itscursor, and a place for relationship metadata. For example,FriendEdge { node: User, since: DateTime }puts data about the relationship on the edge, not the node. - PageInfo:
hasNextPage,hasPreviousPage,startCursor,endCursor.
Many APIs add a nodes shortcut field and an optional totalCount.
Cursors
A cursor encodes where an item sits in a specific ordering, typically the sort key values plus a unique tiebreaker ([placedAt, id]). Make cursors opaque (base64) so clients don't parse or construct them, which leaves you free to change their contents. Validate decoded cursors, since they're user input. Cursors are only meaningful for the same sort and filters; changing the sort order invalidates them.
Keyset Queries
Behind the API, translate after into a keyset (seek) predicate on an indexed ordering:
This uses the index to jump directly to the page, with constant cost no matter how deep. Always include a unique column in the ordering so ties don't cause skipped or repeated items. See query optimization.
Total Counts
COUNT(*) over a large filtered table can cost more than fetching the page itself. Options: make totalCount a separate, optional field resolved only when requested; return an estimate (pg_class.reltuples or planner estimates); cap counting ("1,000+"); or omit it and rely on hasNextPage.
Client Integration
- Relay requires connections for paginated fields and handles merging pages with
usePaginationFragment. - Apollo Client merges pages via type policies:
relayStylePagination()for connections, oroffsetLimitPagination(), plusfetchMore. - urql and TanStack Query-based clients offer similar helpers (see TanStack Query for infinite queries).
Consistent connection shapes across the schema let one client-side helper handle every list.
Best Practices
Paginate Every Unbounded List From the Start
Even if today's list has five items, return a connection. Changing orders: [Order!]! to a connection later breaks every client. See schema design.
Enforce Maximum Page Sizes
Clamp first/last to a maximum (for example 100) and require one of them. Unbounded page sizes are a denial-of-service vector; combine with query cost limits in GraphQL security.
Fetch One Extra Row to Compute hasNextPage
Query limit + 1 rows. If you get the extra row, there's a next page. That's cheaper than a separate count query.
Batch Nested Connections
Connections nested under list items (the first 3 comments of each of 20 posts) create N+1 patterns. Batch them with DataLoader and window-function queries.
Common Mistakes
Cursors That Are Just Offsets
Ordering Without a Unique Tiebreaker
ORDER BY created_at alone skips or duplicates items when several rows share a timestamp. Always add a unique column: ORDER BY created_at DESC, id DESC.
Always Computing totalCount
Resolving an exact count on every page request for a million-row table can dominate response time. Resolve it lazily, only when the field is requested, or approximate it.
FAQ
Do I have to use the Relay connection format?
No, but it's the most widely understood convention and integrates with major clients. Simpler cursor formats (items, nextCursor) work too. Whatever you choose, use it consistently across the schema.
How do I support "jump to page N" with cursors?
Cursor pagination doesn't support arbitrary page jumps. If numbered pages are a real requirement (admin tables, search results), offer offset pagination for that field or a hybrid, and accept its trade-offs on smaller datasets.
What should a cursor contain?
The values of the sort key for that item plus a unique tiebreaker, and optionally a version marker. Encode it opaquely and validate it on input. Don't include sensitive data, since cursors are visible to clients.
How does backward pagination work?
With last and before, the server reverses the keyset comparison and sort order to fetch items preceding the cursor, then reverses the results back to the canonical order before returning. hasPreviousPage is computed with the same "fetch one extra" trick.
Related Topics
- GraphQL — The query language overview
- GraphQL Schema Design — Designing list fields and types
- GraphQL DataLoader — Batching nested connections
- Query Optimization — Keyset queries and indexes
- API Design — Pagination in REST and other APIs
- TanStack Query — Infinite queries on the client