GraphQL Pagination: Offset, Cursor, and Relay Connections Explained

A practical guide to GraphQL pagination: offset, cursor (keyset), and Relay Connections—trade-offs, SQL patterns, and battle-tested implementation tips.

ASOasis
8 min read
GraphQL Pagination: Offset, Cursor, and Relay Connections Explained

Image used for representation purposes only.

Why Pagination Design Matters in GraphQL

Pagination looks simple—return a slice of a list—but the choices you make ripple through performance, correctness, and developer experience. In GraphQL, you’ll most often see three approaches:

  • Offset-based pagination (offset/limit)
  • Cursor-based pagination (a.k.a. keyset pagination)
  • Relay-style Connections (edges/node + pageInfo)

This article explains how each works, where they shine or fail, and how to implement and migrate between them without surprising consumers.

Offset-Based Pagination

Offset pagination exposes arguments like offset and limit:

# Query shape
query Articles($offset: Int!, $limit: Int!) {
  articles(offset: $offset, limit: $limit, orderBy: { createdAt: DESC }) {
    id
    title
    createdAt
  }
}

Typical SQL:

SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT $limit OFFSET $offset;

Pros:

  • Simple mental model; easy to add to existing endpoints.
  • Random access to “page N” is trivial.

Cons:

  • Unstable under concurrent writes: inserts/deletes between requests shift rows, causing duplicates or skips.
  • Performance degrades as offset grows; databases must scan/skip many rows.
  • Backward pagination is awkward; you compute offsets manually.

Good fit when:

  • Small, bounded lists (admin dashboards, one-off tools).
  • You truly need random access by page number, and data is low-churn.

Cursor-Based (Keyset) Pagination

Cursor pagination exposes opaque cursors that represent a specific position in an ordered set. Instead of “page 3,” clients say “give me the next 20 after this cursor.”

GraphQL example (non-Relay):

# Schema
input ArticleSort {
  field: ArticleSortField!
  direction: SortDirection! # ASC|DESC
}

type ArticleConnectionLike {
  nodes: [Article!]!
  pageInfo: PageInfoLike!
}

type PageInfoLike {
  hasNextPage: Boolean!
  endCursor: String
}

# Query
query Articles($first: Int!, $after: String, $orderBy: ArticleSort) {
  articles(first: $first, after: $after, orderBy: $orderBy) {
    nodes { id title createdAt }
    pageInfo { hasNextPage endCursor }
  }
}

Implementation notes:

  • Choose a stable, unique sort order. Common choice: ORDER BY created_at DESC, id DESC.
  • Encode a cursor from the last row, e.g. base64("{"createdAt":"2025-07-21T10:03:00Z","id":1234}").
  • To fetch the next page, filter with strict inequalities that match the sort direction:
-- For DESC order (newest first):
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) < ($cursor_created_at, $cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT $first + 1; -- Fetch one extra to compute hasNextPage

Pros:

  • Stable under concurrent writes; no row shifting.
  • Consistent performance; no huge OFFSET scans.
  • Naturally supports infinite scrolling and streaming.

Cons:

  • No built-in “go to page N.”
  • Requires careful handling of sort keys and tie-breakers.

Good fit when:

  • Large, frequently changing datasets.
  • Mobile/web feeds and timelines.

Relay Connections: A Standardized Cursor API

Relay popularized a formal Connection model that many GraphQL servers adopt even if they don’t use Relay on the client.

Key elements:

  • A Connection type wraps a list as edges (with cursor) and node (the item), plus pageInfo with pagination state.
  • Forward pagination uses first and after.
  • Backward pagination uses last and before.
  • Cursors are opaque strings.

Schema sketch:

# Generic building blocks
interface Node { id: ID! }

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

type ArticleEdge { node: Article! cursor: String! }

type ArticleConnection {
  edges: [ArticleEdge!]!
  nodes: [Article!]! # optional but convenient
  pageInfo: PageInfo!
}

extend type Query {
  articles(
    first: Int, after: String,
    last: Int, before: String,
    orderBy: ArticleSort = { field: CREATED_AT, direction: DESC }
  ): ArticleConnection!
}

Typical client query:

query GetArticles($first: Int!, $after: String) {
  articles(first: $first, after: $after) {
    edges {
      cursor
      node { id title createdAt }
    }
    pageInfo { hasNextPage endCursor }
  }
}

A Relay-compliant resolver must also guard against invalid argument combinations (e.g., both first and last, or missing bounds) and enforce maximum page sizes.

Building Robust Cursors

  • Opaque by design: encode JSON with base64 to avoid leaking internals and to support future additions.
  • Include all fields needed to resume ordering deterministically. For DESC(created_at), ID as a tie-breaker is essential.
  • Inclusive vs exclusive: Use exclusive inequalities (< or >) to avoid duplicates across pages.
  • Forward vs backward:
    • Forward (first/after): for DESC order, use WHERE (created_at, id) < (cursor.createdAt, cursor.id) ORDER BY created_at DESC, id DESC.
    • Backward (last/before): invert the comparison (>), fetch LIMIT last + 1, then reverse results in memory before returning.

TypeScript-style helper:

type Cursor = { createdAt: string; id: number };
const encode = (c: Cursor) => Buffer.from(JSON.stringify(c)).toString('base64');
const decode = (s: string): Cursor => JSON.parse(Buffer.from(s, 'base64').toString('utf8'));

PageInfo computation pattern:

  1. Fetch pageSize + 1 rows.
  2. hasNextPage is rows.length > pageSize (trim the extra).
  3. endCursor is encode(lastRow).
  4. startCursor is encode(firstRow). For backward pagination, reverse rows before computing edges.

SQL Patterns for Keyset Pagination

Compound key example for DESC(created_at, id):

-- Forward
SELECT *
FROM articles
WHERE (created_at, id) < ($c_created_at, $c_id)
ORDER BY created_at DESC, id DESC
LIMIT $first + 1;

-- Backward
SELECT *
FROM articles
WHERE (created_at, id) > ($c_created_at, $c_id)
ORDER BY created_at ASC, id ASC  -- invert to fetch efficiently
LIMIT $last + 1;
-- reverse rows in app layer before returning

Indexing:

  • Create a composite index matching the ordering: CREATE INDEX ON articles (created_at DESC, id DESC);
  • For backward fetch, databases may still use the same index but in reverse; verify with EXPLAIN.

Returning totalCount (and Why It’s Tricky)

Relay’s spec intentionally omits totalCount because exact counting can be expensive and misleading in high-churn lists. If you must expose it:

  • Compute asynchronously and cache.
  • Use approximate counts (e.g., database table stats) with a disclaimer.
  • Avoid tying UI controls (e.g., “page 42 of 9,183”) to a volatile total.

If you do return it, prefer an optional field:

type ArticleConnection { edges: [ArticleEdge!]! nodes: [Article!]! pageInfo: PageInfo! totalCount: Int }

Supporting Sorts and Filters

Cursors must align with the active sort and filter set. If clients can filter by author or tag, include those parameters outside the cursor (as query args) so a cursor cannot be mistakenly reused across different filters. Keep the cursor strictly about position, not about arbitrary query state.

A safe pattern:

  • Filters and sort options are explicit args.
  • Cursor only encodes the ordered key values.
  • Server validates that a cursor is only used with the same sort direction it was generated for.

Migration: Offset to Cursor Without Breaking Clients

  1. Introduce a new field alongside the old one, e.g. articlesConnection in parallel with articles.
  2. Mark offset-based args as deprecated in SDL; keep behavior stable.
  3. For a transitional experience, accept offset/limit but internally translate offset to a starting cursor by performing one seed query to locate the start boundary. Document that this is approximate when data churns.
  4. Announce a cutoff date; remove offsets after adoption.

Example transition SDL:

extend type Query {
  articles(
    offset: Int @deprecated(reason: "Use Relay-style Connection"),
    limit: Int @deprecated(reason: "Use Relay-style Connection")
  ): [Article!]!

  articlesConnection(
    first: Int, after: String, last: Int, before: String
  ): ArticleConnection!
}

Resolver Walkthrough (Node + SQL/Prisma-style Pseudocode)

const DEFAULT_PAGE = 20;
const MAX_PAGE = 100;

async function resolveArticlesConnection(_, args, ctx) {
  const forward = args.first != null;
  const pageSize = Math.min(args.first ?? args.last ?? DEFAULT_PAGE, MAX_PAGE);

  // Decode cursor if provided
  const c = args.after ? decode(args.after) : args.before ? decode(args.before) : null;

  // Build WHERE and ORDER BY
  const order = { createdAt: 'desc', id: 'desc' } as const;
  const comparator = forward ? '<' : '>';
  const orderSql = forward ? 'created_at DESC, id DESC' : 'created_at ASC, id ASC';

  const bindings = [] as any[];
  let where = '';
  if (c) { where = `WHERE (created_at, id) ${comparator} ($1, $2)`; bindings.push(c.createdAt, c.id); }

  const limit = pageSize + 1; // one extra to detect next/prev

  const rows = await ctx.db.query(
    `SELECT id, title, created_at FROM articles ${where} ORDER BY ${orderSql} LIMIT ${limit}`,
    bindings
  );

  // Adjust for backward pagination
  const sliced = rows.slice(0, pageSize);
  const data = forward ? sliced : sliced.reverse();

  const edges = data.map(r => ({ node: r, cursor: encode({ createdAt: r.created_at, id: r.id }) }));

  const pageInfo = {
    hasNextPage: forward ? rows.length > pageSize : Boolean(args.before),
    hasPreviousPage: forward ? Boolean(args.after) : rows.length > pageSize,
    startCursor: edges[0]?.cursor ?? null,
    endCursor: edges[edges.length - 1]?.cursor ?? null,
  };

  return { edges, nodes: data, pageInfo };
}

Notes:

  • hasNextPage/hasPreviousPage depend on direction and whether you fetched an extra row.
  • In real code, compute both flags by running two directional probes or by checking existence of additional rows beyond the slice.

Edge Cases and Gotchas

  • Deletions between requests: Keyset pagination won’t duplicate/skip, but item counts vary; design UIs that tolerate non-deterministic totals.
  • Non-unique sort keys: Always add a unique tiebreaker (ID). Without it, you’ll see duplication across pages.
  • Secondary indexes: Ensure your ORDER BY matches an index. Covering indexes avoid extra heap lookups.
  • Backfills and re-sorts: Changing ORDER BY in production invalidates existing cursors. Version your cursor format, or reject old cursors with a friendly error.
  • Large page sizes: Impose max limits server-side to protect the database.
  • N+1 queries: Edges with node fields can trigger per-row loaders; use batch loaders (e.g., DataLoader) to avoid N+1.

When to Choose Which

  • Choose offset when:
    • Lists are small, stable, and require page-number UX.
  • Choose cursor when:
    • Data is large or high-churn; infinite scroll; predictable performance is required.
  • Choose Relay Connections when:
    • You want a standardized, tooling-friendly API with forward/backward pagination and strong semantics, regardless of client framework.

Practical Checklist

  • Pick a stable order with a unique tie-breaker.
  • Encode opaque cursors; keep them small but extensible (e.g., base64 JSON).
  • Fetch pageSize + 1 to compute hasNext/hasPrevious.
  • Validate arguments (first/after vs last/before) and enforce max page sizes.
  • Align indexes to ORDER BY.
  • Keep filters as explicit args; don’t bake them into cursors.
  • Consider omitting totalCount or compute it asynchronously.
  • Version your cursor format to survive schema evolution.

Closing Thoughts

Offset pagination is familiar but brittle at scale. Cursor (keyset) pagination offers stability and speed, and the Relay Connection model wraps it with a predictable contract that plays well with GraphQL tooling. Start with keyset + Relay when building new APIs; keep offset only where you truly need page numbers and the datasets are small. With careful cursor design, you’ll deliver smooth scrolling, consistent results, and happy clients.

Related Posts