REST vs GraphQL Pagination Contract Tradeoffs
This page sits under Offset vs Cursor Pagination, part of the Query Patterns & Data Shaping Strategies reference. It compares two ways to expose the same keyset pagination technique: a REST ?limit=&cursor= contract with a meta.next_cursor, versus a GraphQL Relay Connection with edges, node, cursor, and pageInfo. The database work is nearly identical — see Implementing Cursor-Based Pagination with PostgreSQL — so the decision is entirely about the contract you publish and how it caches, counts, and generates clients.
The Decision Trigger
You are designing a list endpoint and someone asks “should this be REST or GraphQL?” The pagination contract is where that choice has the most durable consequences, because it leaks into every client, every cache layer, and every SDK you generate. Pick based on these signals:
| Signal | Leans REST | Leans GraphQL |
|---|---|---|
| CDN / HTTP caching is central to delivery | Yes — GET + URL cache key | No — POST bodies bypass HTTP caches |
| Clients need many related collections in one round trip | No | Yes — nested connections |
| Consumers are third parties with simple HTTP tooling | Yes | No — needs a GraphQL client |
| Field-level over-fetch control matters | Partial (sparse fieldsets) | Yes — selection sets |
Side-by-Side Contract Comparison
The core tradeoff table — the same page of data, two envelopes:
| Dimension | REST cursor pagination | GraphQL Relay connection |
|---|---|---|
| Request | GET /v1/items?limit=50&cursor=abc |
POST /graphql with items(first: 50, after: "abc") |
| Page envelope | { data: [...], meta: { next_cursor } } |
{ edges: [{ node, cursor }], pageInfo } |
| Next-page signal | meta.next_cursor (null at end) |
pageInfo.hasNextPage + pageInfo.endCursor |
| Per-item cursor | No (page boundary only) | Yes (every edge has a cursor) |
| Over-fetching control | Sparse fieldsets / fields= param |
Selection set (client picks fields) |
| HTTP caching | Native (GET, ETag, Cache-Control) |
None by default; needs persisted queries |
| Total count | Optional meta.total |
Optional totalCount on the connection |
| Generated client | list({limit, cursor}) returns {data, meta} |
Typed connection; often needs an iterator helper |
| Error model | HTTP status + RFC 9457 body | 200 OK + errors[] array |
The headline: REST wins on caching and tooling simplicity; GraphQL wins on over-fetch control and fetching multiple collections in one round trip. Everything below expands these rows.
The REST Contract
REST puts the cursor in the URL and the continuation token in a meta block. This is a plain, cacheable GET.
# openapi/paths/items.yaml (OpenAPI 3.1.0)
openapi: 3.1.0
info: { title: Items API, version: 1.0.0 }
paths:
/v1/items:
get:
operationId: listItems
parameters:
- { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } }
- { name: cursor, in: query, schema: { type: string } }
responses:
"200":
description: A page of items
content:
application/json:
schema:
type: object
required: [data, meta]
properties:
data:
type: array
items: { $ref: "#/components/schemas/Item" }
meta:
type: object
required: [has_next_page]
properties:
next_cursor: { type: string, nullable: true }
has_next_page: { type: boolean }
total: { type: integer, description: "Optional; may be an estimate" }
components:
schemas:
Item:
type: object
properties:
id: { type: string }
name: { type: string }
The next_cursor is an opaque token — build it as described in Encoding Opaque Cursor Tokens with Base64URL. Because the whole request is a URL, a CDN can cache the exact page under its full-URL key.
The GraphQL Contract
GraphQL standardises on the Relay Connection spec: a Connection has edges (each an Edge with a node and a cursor) and a pageInfo.
# schema.graphql — Relay Connection shape
type Query {
items(first: Int = 50, after: String, last: Int, before: String): ItemConnection!
}
type ItemConnection {
edges: [ItemEdge!]!
pageInfo: PageInfo!
totalCount: Int # optional; nullable so servers can skip the COUNT
}
type ItemEdge {
node: Item!
cursor: String! # opaque, per-item — resume from any edge
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Item {
id: ID!
name: String!
}
A client query selects only the fields it needs — the over-fetching advantage:
query {
items(first: 50, after: "abc") {
edges { cursor node { id name } }
pageInfo { hasNextPage endCursor }
}
}
Over-Fetching
REST returns a fixed representation per endpoint. To trim it you add a sparse-fieldset mechanism, as covered in Adding Sparse Fieldset Support to OpenAPI Specs — a fields=id,name parameter that projects columns. GraphQL bakes this in: the selection set is the projection, so clients never receive a field they did not ask for. The cost is that every distinct selection set is a distinct query shape, which is exactly what complicates caching.
Caching: The Central Divergence
This is the sharpest tradeoff. REST pagination is a GET, so it participates in the entire HTTP caching stack for free:
GET /v1/items?limit=50&cursor=abc HTTP/1.1
HTTP/1.1 200 OK
Cache-Control: public, max-age=60, stale-while-revalidate=30
ETag: "items-abc-v1"
Any CDN, reverse proxy, or browser caches that response keyed on the URL. GraphQL sends queries as POST bodies to a single /graphql endpoint, which HTTP caches treat as uncacheable by default. You recover caching only by adopting persisted queries (the client sends a hash of a pre-registered query, turning it into a cacheable GET) or a normalised client cache such as Apollo or Relay that stores objects by ID. Both work well but are additional machinery you must operate. This mirrors the broader statelessness and caching strategies that favour cache-friendly GETs at the edge.
Total Counts
Neither style should return an exact COUNT(*) by default over a large filtered set — it is often as costly as the page query and drifts under concurrent writes. Both make the count optional: REST via an optional meta.total, GraphQL via a nullable totalCount. Prefer a has_next_page / hasNextPage boolean derived from fetching limit + 1 rows, and expose the total only as an opt-in, ideally an estimate (reltuples in PostgreSQL) when the client only needs an order of magnitude.
Generated-Client Ergonomics
An OpenAPI generator turns the REST contract into a direct call: client.listItems({ limit: 50, cursor }) returning { data, meta }. Consumers usually still want an iterator, but it is a thin wrapper:
export async function* allItems(api: ItemsApi, limit = 50) {
let cursor: string | undefined;
do {
const { data, meta } = await api.listItems({ limit, cursor });
yield* data;
cursor = meta.next_cursor ?? undefined;
} while (cursor);
}
A GraphQL codegen tool produces typed connection objects, but the nested edges[].node shape means most teams ship a helper that flattens edges and follows pageInfo.endCursor:
export async function* allItemsGql(client: GraphQLClient, pageSize = 50) {
let after: string | undefined;
while (true) {
const { items } = await client.request(ITEMS_QUERY, { first: pageSize, after });
for (const edge of items.edges) yield edge.node;
if (!items.pageInfo.hasNextPage) break;
after = items.pageInfo.endCursor;
}
}
The Relay shape is more verbose to consume but its per-edge cursor lets a client resume from any item, not just a page boundary — useful for infinite-scroll UIs that reconcile against a moving list.
RFC and Standard Alignment
| Standard | Relevance |
|---|---|
| RFC 9110 (HTTP Semantics) | REST pagination is a safe, idempotent GET; caching and status codes follow from it |
| RFC 4648 §5 | base64url encoding shared by both REST cursors and Relay cursor strings |
| Relay Cursor Connections spec | Defines edges, node, cursor, pageInfo, hasNextPage, endCursor |
| RFC 9457 (Problem Details) | REST error bodies; GraphQL instead uses a 200 with an errors[] array |
Common Mistakes
| Mistake | Correct approach |
|---|---|
Expecting HTTP caches to cache GraphQL POST queries |
Use persisted queries or a normalised client cache; only GETs cache for free |
Returning an exact COUNT(*) on every page |
Derive has_next_page from limit + 1; make total counts opt-in / estimated |
| Exposing offset/page numbers in a GraphQL connection | Use opaque cursor + pageInfo; offsets reintroduce keyset instability |
Letting clients parse REST next_cursor or Relay cursor |
Keep both opaque and signed; document them as non-parseable handles |
Treating a GraphQL 200 with errors[] as success |
Inspect the errors array; a 200 can still be a partial or full failure |
FAQ
Is GraphQL Relay pagination just cursor pagination with extra structure?
Essentially yes. Relay connections are keyset cursor pagination wrapped in a standardised envelope: edges pairs each node with its own cursor, and pageInfo carries hasNextPage and endCursor. The database technique underneath is identical to REST cursor pagination — a WHERE (sort_key, id) < (...) keyset seek. Relay’s contribution is fixing the field names across the ecosystem and adding per-item cursors so a client can resume from any edge rather than only from a page boundary.
Why is REST pagination easier to cache than GraphQL?
REST pagination is a GET with the cursor in the URL, so any HTTP cache, CDN, or browser can key on the full URL and store the response with Cache-Control and ETag. GraphQL queries are usually POST bodies sent to a single endpoint, which HTTP caches ignore because the cache key would have to include the request body. You regain caching only with persisted queries (which turn a query into a cacheable GET by hash) or a normalised client cache like Apollo — both effective, both extra infrastructure to run.
Should a paginated endpoint return a total count?
Only when it is cheap and the client genuinely needs it. An exact COUNT(*) over a large filtered set is frequently as expensive as fetching the page itself, and it grows less accurate under concurrent inserts and deletes. Prefer a hasNextPage boolean derived from fetching limit + 1 rows. Expose total / totalCount as an optional, nullable field so clients that need an approximate figure can opt in, ideally backed by a fast estimate rather than a live scan.
Related
- Offset vs Cursor Pagination — the section comparing pagination strategies that frames this REST-versus-GraphQL decision
- Query Patterns & Data Shaping Strategies — the parent reference covering filtering, sorting, pagination, and projection
- Implementing Cursor-Based Pagination with PostgreSQL — the shared keyset technique underneath both contract styles
- Debugging Projection Field Validation Errors — the projection concerns that map to GraphQL selection sets and REST sparse fieldsets
- Encoding Opaque Cursor Tokens with Base64URL — how the opaque cursor strings in both contracts are built and verified