backend / protocols / graphql / 05_pagination_connections.md

GraphQL Pagination — Relay Connections

6 interview angles 6 min read source

GraphQL Pagination — Relay Connections

GraphQL doesn’t mandate a pagination style. The de facto standard is Relay Connections — cursor-based pagination with a specific schema shape. Many tools (Relay, Apollo) work best with this convention; using it from day one saves rewriting later.

The shapes

Three common approaches:

Style When
Offset/limit small datasets, admin UIs (“page 3 of 5”)
Simple cursor most common in modern APIs
Relay Connections when using Relay or for ecosystem compatibility

Offset/limit (the simplest)

type Query {
  posts(limit: Int = 20, offset: Int = 0): [Post!]!
}
query {
  posts(limit: 10, offset: 30) {
    id
    title
  }
}

Same problems as REST offset pagination — slow for deep pages (OFFSET 1000000 scans + discards). Drifts if rows are inserted between page loads. Fine for small bounded datasets.

Simple cursor pagination

type Query {
  posts(first: Int = 20, after: String): PostPage!
}

type PostPage {
  items: [Post!]!
  nextCursor: String
  hasMore: Boolean!
}
query {
  posts(first: 10) {
    items { id title }
    nextCursor
    hasMore
  }
}

# Next page:
query {
  posts(first: 10, after: "eyJpZCI6MTAwfQ==") {
    items { id title }
    nextCursor
  }
}

Cursor is opaque (typically base64-encoded JSON of the last-seen sort key). Constant-time at any depth. The simpler alternative to Relay’s full spec.

Relay Connections — the standard

A specific connection-edge-node schema shape that Relay and most GraphQL tooling expects.

type Query {
  posts(first: Int, after: String, last: Int, before: String): PostConnection!
}

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int             # optional, expensive
}

type PostEdge {
  node: Post!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

type Post {
  id: ID!
  title: String!
  # ...
}

Query:

query {
  posts(first: 10) {
    edges {
      cursor
      node { id title }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

Response:

{
  "data": {
    "posts": {
      "edges": [
        {"cursor": "abc1", "node": {"id": "1", "title": "..."}},
        {"cursor": "abc2", "node": {"id": "2", "title": "..."}}
      ],
      "pageInfo": {
        "hasNextPage": true,
        "endCursor": "abc2"
      }
    }
  }
}

Client uses endCursor for the next page:

query {
  posts(first: 10, after: "abc2") { ... }
}

Why the edges/node split?

Looks verbose for “just a list of posts.” The split allows:

  • Edge-level metadata — properties of the relationship, not the node. E.g. for user.posts, Edge could carry addedAt, pinned, role.
  • Pagination per relationshipuser.posts(first: 10) is naturally connection-shaped.
  • Consistency across types — every paginated field looks the same; clients and tools (Apollo, Relay) handle them uniformly.
type PostEdge {
  node: Post!
  cursor: String!
  addedToCollectionAt: DateTime    # relationship metadata
  pinned: Boolean!
}

If you don’t need edge metadata, the edges wrapper is overhead. Some teams use a flat items + pageInfo shape (the “simple cursor” style above).

Forward and backward pagination

Relay defines both directions:

Args Direction
first: Int, after: String forward
last: Int, before: String backward

Backward pagination is rare in practice (“show me the page before this one”). Most clients only use forward. Implement backward only if you need it.

You can also mix: first: 10, after: "abc" plus last: 10, before: "xyz" etc. — see the Relay spec for edge cases.

Cursor encoding

import base64, json

def encode_cursor(last_seen_id: int, last_seen_created: datetime) -> str:
    return base64.urlsafe_b64encode(
        json.dumps({"id": last_seen_id, "created": last_seen_created.isoformat()}).encode()
    ).decode()

def decode_cursor(cursor: str) -> dict:
    return json.loads(base64.urlsafe_b64decode(cursor))

Why base64 JSON: opaque to clients (they don’t depend on the format), easy for the server to evolve (add fields, change encoding).

The SQL behind the cursor:

SELECT * FROM posts
WHERE (created_at, id) < (:cursor_created, :cursor_id)   -- tuple comparison
ORDER BY created_at DESC, id DESC
LIMIT :first + 1                                          -- +1 to know hasNextPage

Tuple comparison handles ties — when multiple rows share created_at, id breaks the tie.

The “+1” technique:

  • Fetch N+1 rows.
  • If you got N+1, there’s a next page (set hasNextPage = true); return the first N.
  • If you got ≤ N, no next page; return what you have.

Avoids a separate COUNT query.

totalCount — usually skip

type PostConnection {
  totalCount: Int!     # expensive
  edges: [PostEdge!]!
  pageInfo: PageInfo!
}

totalCount requires SELECT COUNT(*) FROM posts WHERE ... — same problem as REST. On a table with 50M rows, it’s seconds.

If clients don’t strictly need it, omit. If they do, make it optional (nullable) and let resolver short-circuit for expensive filters.

For approximate counts on Postgres:

SELECT reltuples::bigint FROM pg_class WHERE relname = 'posts'

Returns an approximate row count from statistics — fast but stale.

Nested pagination

query {
  user(id: 1) {
    posts(first: 10) {
      edges {
        node {
          id
          comments(first: 5) {
            edges { node { id body } }
          }
        }
      }
    }
  }
}

For each post (10 of them), fetch 5 comments. Naively this is N+1 (one query per post for comments). DataLoader fixes it — see 04_resolvers_n_plus_1_dataloader.md — but pagination complicates DataLoader (different first per call).

Common solution: window-based batch query:

SELECT * FROM (
  SELECT *, row_number() OVER (PARTITION BY post_id ORDER BY created_at DESC) AS rn
  FROM comments
  WHERE post_id = ANY($1)
) WHERE rn <= 5

One query, returns top-5 comments per post. Resolver groups by post_id.

Implementation in Strawberry

import strawberry
from typing import Optional
from base64 import b64encode, b64decode

@strawberry.type
class PageInfo:
    has_next_page: bool
    end_cursor: Optional[str]

@strawberry.type
class PostEdge:
    node: "Post"
    cursor: str

@strawberry.type
class PostConnection:
    edges: list[PostEdge]
    page_info: PageInfo

@strawberry.type
class Query:
    @strawberry.field
    async def posts(
        self,
        first: int = 20,
        after: Optional[str] = None,
    ) -> PostConnection:
        cursor_id = decode_cursor(after) if after else None
        rows = await db.fetch_all(
            "SELECT * FROM posts WHERE ($1::int IS NULL OR id < $1) ORDER BY id DESC LIMIT $2",
            cursor_id, first + 1,
        )
        has_next = len(rows) > first
        items = rows[:first]
        edges = [PostEdge(node=Post(**r), cursor=encode_cursor(r["id"])) for r in items]
        return PostConnection(
            edges=edges,
            page_info=PageInfo(
                has_next_page=has_next,
                end_cursor=edges[-1].cursor if edges else None,
            ),
        )

Most Python GraphQL libraries (Strawberry, Ariadne, Graphene) have helpers for Connection-style pagination.

Common pitfalls

  • Offset pagination at depthOFFSET 100000 LIMIT 50 is slow. Use cursor.
  • Cursor on non-unique field — ties skip or duplicate rows. Tiebreak with id (or unique column).
  • totalCount on every paginated field — expensive; make optional.
  • Different pagination per field — one uses offset, another cursor. Inconsistent for clients. Standardize.
  • No +1 peek — separate COUNT query for hasNextPage. Fetch N+1 instead.
  • Cursor format is JSON without encoding — clients see DB internals; can’t change format.

Common interview confusions

  • “Connection edges/nodes is for compatibility with relational DBs.” — no, it’s for relationship metadata. The split lets edges carry per-edge data (e.g. pinned on a user-post relationship).
  • “Relay pagination requires totalCount.” — optional. Many implementations omit it for performance.
  • “You need GraphQL Relay library to use Connections.” — Connection is a schema convention. You can implement it in any library.

Interview angle

  • “What’s the Relay Connection pattern?” — schema convention for cursor pagination: a Connection type with edges (each {node, cursor}), pageInfo (hasNextPage, endCursor, etc.), optional totalCount. Args: first, after, last, before. Standard across GraphQL tooling.
  • “Why use cursors over offset?” — constant-time at any depth (offset’s LIMIT 50 OFFSET 1000000 scans + discards 1000000 rows). Doesn’t skip/duplicate when rows are inserted between pages.
  • “Why the edges/node split?” — edges can carry relationship-level metadata (when the user joined the group, whether a post is pinned in a collection). Without edges, that has nowhere natural to live.
  • “How do you detect hasNextPage without a separate COUNT?” — fetch first + 1 rows. If you got first + 1, there’s a next page; return the first N as edges.
  • “How do you handle nested pagination without N+1?” — DataLoader plus a batched window query (e.g. row_number() OVER (PARTITION BY ...) in Postgres) to fetch top-N children per parent in one query.
  • “What’s in a cursor?” — typically base64-encoded JSON of the last-seen sort key ({id, created_at}). Opaque to clients; lets the server evolve the format.