Pagination
|
This section documents the current GraphQL specification (October 2021), plus the working draft for
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as GraphQL tooling iterates quickly. |
Any field that returns a list eventually outgrows returning it in full, and GraphQL leaves the pagination strategy itself up to schema design rather than mandating one in the specification. This page covers the two strategies in practical use — offset-based and cursor-based — and the Relay Cursor Connections specification that the ecosystem converged on as the de facto standard for the latter.
Why every list field eventually needs a pagination strategy
A field such as products: [Product!]! works fine while the underlying list is small, but it has no way to
express "give me the next 20" — a client always receives the entire list, and the server always resolves the
entire list, even when the client only renders the first page of results. Schema
design already flags this: any list field with unbounded growth (products in a catalog, comments on a post,
notifications) should commit to a pagination shape from the start, because retrofitting one later is a breaking
change for every client already querying the unpaginated field.
Offset-based (page-number) pagination and its problems
The most familiar approach mirrors REST’s ?page=2&pageSize=20 or ?offset=20&limit=20 query parameters,
exposed as plain integer arguments:
type Query {
products(offset: Int = 0, limit: Int = 20): [Product!]!
}
query {
products(offset: 20, limit: 20) {
id
name
}
}
This is simple to implement — it usually maps directly onto a SQL OFFSET/LIMIT clause — but it has two
problems that get worse as a list grows or changes underneath the pagination:
-
Page drift under concurrent writes. If an item is inserted or deleted before the current offset while a client is paging through results, every subsequent page shifts by one position — the client can see the same item twice, or skip one entirely, without either side doing anything wrong.
-
Increasingly expensive deep pages. Many databases still have to scan and discard every row before the requested offset, so
OFFSET 100000 LIMIT 20does far more work thanOFFSET 0 LIMIT 20, even though both return the same number of rows.
Offset pagination is a reasonable choice for small, rarely-mutated lists (a fixed reference table, an admin report run once), but a large, actively-written list is exactly the case cursor-based pagination was designed for.
Cursor-based pagination
A cursor identifies a client’s position in a list by referencing an item directly — typically an encoding of that item’s sort key — rather than by a numeric offset that has to be recomputed from the start of the list on every request. "Give me the 20 items after this cursor" stays correct even when items are inserted or deleted elsewhere in the list, because the cursor’s target item hasn’t moved relative to itself.
type Query {
products(first: Int, after: String): [Product!]!
}
That signature alone doesn’t tell a client whether more pages exist, or let it page backward — which is why the ecosystem standardized on a richer shape, described next, instead of every schema inventing its own.
The Relay Cursor Connections specification
The GraphQL Cursor Connections Specification — commonly just called
"the Relay spec" or "connections" — defines a Connection/Edge/PageInfo shape that every cursor-paginated
field follows, so a client that has learned to paginate one connection can paginate any other by the same
pattern without server-specific glue code. It predates, and is independent of, the Relay client library itself;
Apollo Client, urql, and plain fetch calls all consume connections the same way.
type Query {
products(first: Int, after: String, last: Int, before: String): ProductConnection!
}
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
}
type ProductEdge {
cursor: String!
node: Product!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
The Connection and Edge types
A field that supports cursor pagination returns a <Field>Connection object rather than a bare list. The
Connection Types section of the spec requires
that object to expose an edges list; the
Edge Types section requires each edge to carry exactly
two fields — node (the actual item) and cursor (that item’s opaque position marker) — so the cursor
travels alongside the data it identifies instead of being derived separately by the client:
{
"data": {
"products": {
"edges": [
{ "cursor": "b3Bhc3RlOjE=", "node": { "id": "1", "name": "Keyboard" } },
{ "cursor": "b3Bhc3RlOjI=", "node": { "id": "2", "name": "Mouse" } }
]
}
}
}
Schema design already established the <Field>Connection /
<Field>Edge naming convention this section assumes — committing to it consistently across every paginated
field is what makes the pattern learnable once and reused everywhere.
The PageInfo object
PageInfo tells a client whether there is more to fetch in either direction without it having to guess from the
size of edges alone. The
PageInfo section of the spec
requires hasPreviousPage and hasNextPage (both non-null booleans), plus startCursor and endCursor for the
first and last edge actually returned:
query {
products(first: 2) {
edges {
cursor
node { id name }
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
{
"data": {
"products": {
"edges": [
{ "cursor": "b3Bhc3RlOjE=", "node": { "id": "1", "name": "Keyboard" } },
{ "cursor": "b3Bhc3RlOjI=", "node": { "id": "2", "name": "Mouse" } }
],
"pageInfo": {
"hasNextPage": true,
"hasPreviousPage": false,
"startCursor": "b3Bhc3RlOjE=",
"endCursor": "b3Bhc3RlOjI="
}
}
}
}
A client walks forward through the whole list by repeating the request with after set to the previous
response’s endCursor, stopping once hasNextPage is false — never by counting returned edges or assuming a
fixed page size.
Forward pagination: first and after
The Forward pagination arguments
section defines first (a non-negative integer capping how many edges to return) and after (a cursor marking
the position to resume from, exclusive of the item it names):
query NextPage($after: String) {
products(first: 20, after: $after) {
edges { cursor node { id name } }
pageInfo { hasNextPage endCursor }
}
}
{ "after": "b3Bhc3RlOjIw" }
Omitting after starts from the beginning of the list; passing the previous page’s endCursor continues
immediately after the last item already seen.
Backward pagination: last and before
The Backward pagination arguments
section mirrors the forward case: last caps how many edges to return counting backward from the end (or from
before), and before marks the position to resume from moving toward the start of the list:
query PreviousPage($before: String) {
products(last: 20, before: $before) {
edges { cursor node { id name } }
pageInfo { hasPreviousPage startCursor }
}
}
Supporting both directions lets a client implement "load more" in either direction from wherever it currently is, rather than only ever being able to page forward from the start.
The pagination algorithm and combining arguments
The spec’s own Pagination algorithm section
defines slicing precisely: cursors (after/before) are applied first to narrow the candidate list, and only
then are first/last applied to that narrowed range — so a request combining, say, after with first
behaves predictably rather than depending on implementation-specific ordering. The
Edge order section additionally requires edges to be
returned in a stable, consistent order across requests for the same arguments, since cursor pagination’s
correctness depends on that order never silently changing between pages.
The spec permits combining first with last in the same request, but in practice most server implementations
reject or ignore that combination — a client is almost always paginating in one direction at a time, and
allowing both invites ambiguous results that are hard for a client to reason about. Validating that only one
directional pair (first/after or last/before) is supplied per request is a common, defensible
implementation choice layered on top of the spec rather than a requirement the spec itself imposes.
Opaque cursors
A cursor’s value is deliberately unspecified by the spec — only its role (a String a client passes back
unmodified) is. A client must never parse, construct, or otherwise depend on a cursor’s internal structure; a
server is therefore free to change that structure at any time without breaking well-behaved clients. In
practice, a cursor is usually a base64-encoded composite of a type discriminator and a sort key, so it survives
being copied into a URL or a saved query without needing any escaping:
{
"cursor": "b3Bhc3RlOjQy",
"_comment": "base64 decodes to \"opaste:42\" -- an implementation detail, never parsed by a client"
}
Encoding the cursor this way also naturally makes it opaque to inspection: a client that decodes it out of
curiosity sees only what the server chose to expose, not a raw database offset or an internal primary key
scheme it could depend on. The spec’s own
Cursor section states this contract; the same opacity
principle underpins the Node interface’s global object identifiers covered in
Global object identification — both patterns exist so
a client treats an identifier as a token to echo back, never as data to decode and act on.
Total counts
The Relay spec deliberately does not define a total-count field, because computing one can be expensive (a full
COUNT scan) or even meaningless (an infinite or externally-sourced feed) for some connections. Schemas that do
need "47 results" in a UI commonly add a totalCount field directly on the Connection type, alongside — not
instead of — edges and pageInfo:
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
Because totalCount is an extension rather than part of the spec, it is worth resolving lazily and only when a
client actually selects it — a query that omits totalCount should not pay for the count query at all, which
Execution & resolvers covers in terms of how field-level
resolvers are only invoked for fields the client actually selected.
Choosing between offset and cursor pagination
| Offset-based | Cursor-based (Relay connections) | |
|---|---|---|
Stable under concurrent writes |
No — items can shift between pages |
Yes — a cursor tracks an item, not a position |
Deep-page performance |
Degrades with offset depth on most databases |
Consistent — each page starts from a known item |
Random access ("jump to page 7") |
Yes, trivially |
No — only sequential forward/backward walking |
Client tooling support |
Ad hoc per schema |
Broadly recognized by GraphQL clients and tooling |
Random access is the one capability cursor pagination gives up — a UI with numbered page links genuinely needs offset semantics. Everything else favors cursors once a list is large, actively written, or exposed to more than one client.
Implementing a connection resolver
Over the wire, the pattern is a normal POST request carrying a query and variables like any other GraphQL
operation — there is nothing pagination-specific about the transport itself:
curl -X POST https://api.example.com/graphql \
-H "Content-Type: application/json" \
-d '{
"query": "query($after: String) { products(first: 2, after: $after) { edges { cursor node { id name } } pageInfo { hasNextPage endCursor } } }",
"variables": { "after": null }
}'
On the resolver side, a connection field typically fetches one more row than first requested (limit + 1),
uses the presence of that extra row to set hasNextPage without a separate COUNT query, and then discards it
before building edges:
# Pseudocode shape of what the resolver does, not a query itself:
# 1. Decode `after` into a sort-key value (or start from the beginning if absent).
# 2. Fetch `first + 1` rows with that sort key greater than the decoded value.
# 3. hasNextPage = (rows returned > first); drop the extra row before mapping to edges.
# 4. Encode each returned row's sort key as that edge's opaque cursor.
Performance \& N+1 covers batching the node side of each
edge (loading every returned product’s related data in one batch rather than once per edge), which applies to
connection resolvers exactly as it does to any other list field.
See also
GraphQL’s offset-vs-cursor choice is the same trade-off every store in this site’s Database Development section
faces at its own query layer: see Pagination: Offset vs. Keyset for why
deep offsets degrade regardless of store, and how SQL’s seek method, MongoDB’s range-query alternative to
skip(), Solr’s cursorMark, Elasticsearch’s search_after and Spring Data’s Window<T> scrolling all
implement the same keyset pattern that Relay cursor connections implement here.
Related pages
-
Schema design— the<Field>Connection/<Field>Edgenaming convention this page builds on. -
Global object identification— theNodeinterface and opaque global IDs, which share the "opaque token, never decoded by a client" principle with cursors. -
Execution & resolvers— how field selection determines which resolvers (including an optionaltotalCount) actually run. -
Performance & N+1— batching the data behind each edge’snodefield. -
Caching— caching individual nodes returned inside a connection.