GraphQL Pagination

Any list in an API that can grow without limit (orders, comments, search results, audit logs) needs pagination. In GraphQL, the de facto standard is cursor-based pagination using the Relay Connection pattern: a list field returns a Connection object with edges (each holding a node and an opaque cursor) and pageInfo telling the client whether more pages exist.

The pattern looks verbose at first, but it's stable under inserts and deletes, maps cleanly to efficient keyset database queries, supports infinite scroll and "load more" naturally, and is understood out of the box by Relay and Apollo client caches. It's worth adopting from the first list field, because retrofitting pagination later is a breaking change.

TL;DR

Quick Example

Schema:

Client query for the next page:

Resolver using a keyset query (TypeScript):

Core Concepts

Offset vs Cursor

Offset pagination is acceptable for small, admin-style tables that need numbered pages. For feeds, timelines, and large or frequently changing data, use cursors.

The Connection Specification

The Relay Cursor Connections spec standardizes:

Many APIs add a nodes shortcut field and an optional totalCount.

Cursors

A cursor encodes where an item sits in a specific ordering, typically the sort key values plus a unique tiebreaker ([placedAt, id]). Make cursors opaque (base64) so clients don't parse or construct them, which leaves you free to change their contents. Validate decoded cursors, since they're user input. Cursors are only meaningful for the same sort and filters; changing the sort order invalidates them.

Keyset Queries

Behind the API, translate after into a keyset (seek) predicate on an indexed ordering:

This uses the index to jump directly to the page, with constant cost no matter how deep. Always include a unique column in the ordering so ties don't cause skipped or repeated items. See query optimization.

Total Counts

COUNT(*) over a large filtered table can cost more than fetching the page itself. Options: make totalCount a separate, optional field resolved only when requested; return an estimate (pg_class.reltuples or planner estimates); cap counting ("1,000+"); or omit it and rely on hasNextPage.

Client Integration

Consistent connection shapes across the schema let one client-side helper handle every list.

Best Practices

Paginate Every Unbounded List From the Start

Even if today's list has five items, return a connection. Changing orders: [Order!]! to a connection later breaks every client. See schema design.

Enforce Maximum Page Sizes

Clamp first/last to a maximum (for example 100) and require one of them. Unbounded page sizes are a denial-of-service vector; combine with query cost limits in GraphQL security.

Fetch One Extra Row to Compute hasNextPage

Query limit + 1 rows. If you get the extra row, there's a next page. That's cheaper than a separate count query.

Batch Nested Connections

Connections nested under list items (the first 3 comments of each of 20 posts) create N+1 patterns. Batch them with DataLoader and window-function queries.

Common Mistakes

Cursors That Are Just Offsets

Ordering Without a Unique Tiebreaker

ORDER BY created_at alone skips or duplicates items when several rows share a timestamp. Always add a unique column: ORDER BY created_at DESC, id DESC.

Always Computing totalCount

Resolving an exact count on every page request for a million-row table can dominate response time. Resolve it lazily, only when the field is requested, or approximate it.

FAQ

Do I have to use the Relay connection format?

No, but it's the most widely understood convention and integrates with major clients. Simpler cursor formats (items, nextCursor) work too. Whatever you choose, use it consistently across the schema.

How do I support "jump to page N" with cursors?

Cursor pagination doesn't support arbitrary page jumps. If numbered pages are a real requirement (admin tables, search results), offer offset pagination for that field or a hybrid, and accept its trade-offs on smaller datasets.

What should a cursor contain?

The values of the sort key for that item plus a unique tiebreaker, and optionally a version marker. Encode it opaquely and validate it on input. Don't include sensitive data, since cursors are visible to clients.

How does backward pagination work?

With last and before, the server reverses the keyset comparison and sort order to fetch items preceding the cursor, then reverses the results back to the canonical order before returning. hasPreviousPage is computed with the same "fetch one extra" trick.

Related Topics

References