Cursor Pagination and Relay Connections

Advanced
13 min

Cursor Pagination and Relay Connections

The pagination overview earlier in this course compared offset and cursor approaches. This chapter implements cursors the way most large GraphQL APIs expose them: the Relay connection specification, with edges, node, cursor and pageInfo. After this lesson you will be able to design a connection type, encode cursors, write an efficient paging resolver, and consume the result from a client.

Why Cursors Beat Offsets

Offset pagination (posts(limit: 20, offset: 40)) has two problems that grow with data size. When rows are inserted or deleted between requests, items shift and the client sees duplicates or gaps. And OFFSET 100000 makes the database scan and discard a hundred thousand rows first.

A cursor is an opaque token that points at a specific item. The client asks for 20 items after that cursor, and the database seeks directly to the position with an indexed WHERE clause. Results are stable and every page costs the same.

The Connection Shape

The Relay specification defines a consistent structure that clients and tooling understand:

graphql
type Query { posts(first: Int = 20, after: String, last: Int, before: String): PostConnection! } type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo! totalCount: Int! } type PostEdge { cursor: String! node: Post! } type PageInfo { hasNextPage: Boolean! hasPreviousPage: Boolean! startCursor: String endCursor: String }

first/after page forward, last/before page backward. Each edge carries its own cursor so a client can resume from any item, and pageInfo tells it whether to show a "load more" control. totalCount is a common addition outside the specification.

Encoding Cursors

A cursor must identify a position in the sort order. For a list sorted by createdAt and then id, both values go into the cursor. Base64-encoding a small JSON string makes the token opaque so clients build no assumptions on its format:

javascript
const encodeCursor = (row) => Buffer.from(JSON.stringify([row.createdAt.toISOString(), row.id])).toString("base64url"); const decodeCursor = (cursor) => JSON.parse(Buffer.from(cursor, "base64url").toString());

Never use an offset number as the cursor; that reintroduces the shifting problem.

The Resolver

Fetch one extra row beyond first to learn whether another page exists without a second query:

javascript
posts: async (_p, { first = 20, after }, { db }) => { const limit = Math.min(first, 100); const params = []; let where = ""; if (after) { const [createdAt, id] = decodeCursor(after); where = "WHERE (created_at, id) < (?, ?)"; params.push(createdAt, id); } const rows = await db.all( `SELECT * FROM posts ${where} ORDER BY created_at DESC, id DESC LIMIT ?`, [...params, limit + 1] ); const page = rows.slice(0, limit); const edges = page.map((row) => ({ cursor: encodeCursor(row), node: row })); return { edges, pageInfo: { hasNextPage: rows.length > limit, hasPreviousPage: Boolean(after), startCursor: edges[0]?.cursor ?? null, endCursor: edges.at(-1)?.cursor ?? null, }, totalCount: (await db.get("SELECT COUNT(*) AS n FROM posts")).n, }; },

The row-value comparison (created_at, id) < (?, ?) uses the same columns as ORDER BY, so a composite index on (created_at, id) serves the query. Clamping first protects the server from a client requesting a million rows.

Consuming a Connection

The client keeps endCursor and sends it back as after:

graphql
query Feed($after: String) { posts(first: 20, after: $after) { edges { node { id title } } pageInfo { hasNextPage endCursor } } }

Apollo Client's relayStylePagination() field policy and Relay merge these pages automatically; with plain fetch, append edges to your list and store the new endCursor.

Tips

  • Sort by a unique tiebreaker (id) as well as the main column; ties on createdAt otherwise skip or repeat rows.
  • Compute totalCount in its own field resolver so the COUNT(*) only runs when a client selects it.
  • Keep offset pagination for admin tables where users jump to page 37; cursors are for feeds.
Quick Quiz
Question 1 of 2

Why is an offset a poor choice as a cursor value?

Key Takeaways

  • Cursor pagination stays stable under inserts and deletes and uses indexed seeks, not OFFSET scans.
  • The Relay connection shape is edges { cursor node } plus pageInfo { hasNextPage endCursor }.
  • Encode the sort key and a unique tiebreaker into an opaque base64 cursor.
  • Fetch one extra row to compute hasNextPage, and clamp first to a maximum.
  • Clients page by passing the last endCursor back as after.

Next lesson: Error Handling Patterns: Codes, Masking and Result Unions — decide which failures are data, which are errors, and how to expose each safely.

Cursor Pagination and Relay Connections - GraphQL | CodeYourCraft | CodeYourCraft