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.