Pagination

This section documents the current GraphQL specification (October 2021), plus the working draft for @defer/@stream/@oneOf, and the GraphQL-over-HTTP specification, as published at graphql.org and spec.graphql.org — which are the references these pages are written and verified against — and, for the integration pages, against Spring for GraphQL (2.0.x), Strawberry, Ariadne, and the client docs for Apollo Client / urql / Relay. No specific patch version is pinned. Some surfaces (Apollo Router/managed federation, graphql-ws internals, the Relay compiler internals, GraalVM native) are linked rather than documented in depth.

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 20 does far more work than OFFSET 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.

  • Schema design — the <Field>Connection/<Field>Edge naming convention this page builds on.

  • Global object identification — the Node interface 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 optional totalCount) actually run.

  • Performance & N+1 — batching the data behind each edge’s node field.

  • Caching — caching individual nodes returned inside a connection.