Skip to content
C Codeloom
GraphQL

GraphQL Pagination: Relay-Style Connections

Understand cursor-based pagination using the Relay connection spec, why it scales better than offsets, and how to implement it cleanly in your schema.

·4 min read · By Codeloom
Intermediate 8 min read

What you'll learn

  • Why offset pagination breaks at scale
  • The Relay connection spec structure
  • How cursors encode position
  • Resolving edges, nodes, and pageInfo
  • Forward vs backward pagination patterns

Prerequisites

  • Familiar with HTTP and databases

What and Why

Pagination in GraphQL has a de facto standard: the Relay connection spec. It uses opaque cursors rather than numeric offsets and structures responses around edges, nodes, and a pageInfo object. Although it began as a Relay client convention, most GraphQL servers and clients today adopt it because it solves real problems that offset pagination cannot.

Offset pagination (LIMIT 20 OFFSET 100) is simple but breaks under three pressures: data shifts between requests cause duplicates or skips, deep offsets force databases to scan and discard huge row counts, and the API leaks implementation details. Cursors fix all three.

Mental Model

A connection is a window over an ordered list. The cursor is a bookmark: when you ask for the next page, you say “give me items after this bookmark”. The server decodes the cursor, jumps to that position using an indexed column, and returns the next slice plus a fresh bookmark.

The shape is intentionally verbose: edges wrap each node with its own cursor, and pageInfo tells the client whether more pages exist. This separation lets the client paginate in either direction without round-trips to discover totals.

Hands-on Example

A typical query:

query {
  posts(first: 10, after: "cursor123") {
    edges {
      cursor
      node { id title }
    }
    pageInfo {
      hasNextPage
      hasPreviousPage
      startCursor
      endCursor
    }
  }
}
  posts(first: 10, after: "X")
     |
     v
+-------------------+
|   Connection      |
|  +-------------+  |
|  | edges []    |  |     edge.cursor -> bookmark per node
|  |  +-------+  |  |
|  |  | node  |  |  |
|  |  +-------+  |  |
|  +-------------+  |
|  | pageInfo    |  |     hasNextPage, endCursor -> next request
|  +-------------+  |
+-------------------+
Relay connection shape and cursor flow

Server-side, resolving this against a SQL table looks roughly like:

async function posts({ first, after }) {
  const afterId = after ? decodeCursor(after) : null;
  const rows = await db.query(
    `SELECT * FROM posts
     WHERE ($1::int IS NULL OR id > $1)
     ORDER BY id ASC
     LIMIT $2 + 1`,
    [afterId, first]
  );
  const hasNextPage = rows.length > first;
  const slice = rows.slice(0, first);
  return {
    edges: slice.map(r => ({ cursor: encodeCursor(r.id), node: r })),
    pageInfo: {
      hasNextPage,
      hasPreviousPage: !!afterId,
      startCursor: slice[0] && encodeCursor(slice[0].id),
      endCursor: slice.at(-1) && encodeCursor(slice.at(-1).id),
    },
  };
}

Cursors are typically base64-encoded so clients treat them as opaque tokens, not as keys to manipulate.

Common Pitfalls

  • Encoding cursors as plain integers. Clients may then build offset math, defeating the spec’s intent. Always base64 and prefix with the type.
  • Forgetting to fetch first + 1 rows. Without that extra row, hasNextPage becomes a guess.
  • Sorting on a non-unique column without a tiebreaker. Two rows with identical timestamps cause page boundaries to skip or repeat. Always include a secondary key like id.
  • Mixing first/after with last/before in the same request. The spec discourages this; pick a direction per call.
  • Returning total counts on every page. They are expensive and often inaccurate during writes. Expose them as a separate optional field only when needed.
  • Letting clients request unbounded first values. Enforce a server-side maximum, typically 100.

Practical Tips

Treat the cursor format as private. Encode the sort key and an ID, sign or version it if you need to evolve the schema, and never let clients construct one manually.

When sorting by something other than ID (created_at, score), include both in the cursor: (createdAt, id) ensures stable ordering even with ties.

Add filters and sorting as separate arguments rather than baking them into the cursor. This keeps the connection contract clean and lets clients change filters without invalidating their pagination state.

For very large datasets, consider keyset pagination semantics under the hood even if the public shape is Relay. The wire format and the storage strategy are independent.

Cache connection results carefully. Apollo and Relay both have specific cache policies for connections; using their helpers (relayStylePagination, @connection) avoids subtle merge bugs.

Wrap-up

Relay-style connections look heavy at first, but they solve hard pagination problems that offsets cannot. Wrap nodes in edges, encode opaque cursors over indexed columns, and let pageInfo drive the client. Once you internalize the shape, it becomes the default pagination pattern across all your GraphQL APIs.