Cursor Pagination the Relay Way

BackendGraphQLAPIArchitecture
Share on LinkedIn

Offset pagination is fine for admin tables nobody scrolls. It's a liability for feeds, chat histories, and any list where rows shift while the user pages. I learned this when a product feed started skipping posts after a deploy introduced concurrent inserts — OFFSET 40 no longer meant "the next page" because 12 new rows landed at the top. Relay-style cursor pagination fixed it without changing the client API shape much.

Offset vs cursor, concretely

Offset:

posts(limit: 20, offset: 40) { title }

SQL: SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 40

Problems at scale:

Cursor:

posts(first: 20, after: "eyJjcmVhdGVkQXQiOjE3...") {
  edges { node { title } cursor }
  pageInfo { hasNextPage endCursor }
}

SQL: SELECT * FROM posts WHERE (created_at, id) < ($cursor_time, $cursor_id) ORDER BY created_at DESC, id DESC LIMIT 21

The extra row (21 instead of 20) tells you hasNextPage without a COUNT query.

Relay Connection schema

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

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
}

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

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

Arguments follow Relay conventions:

Encoding cursors

Cursors must be opaque to clients — they encode position, not identity alone:

function encodeCursor(row) {
  const payload = JSON.stringify({ t: row.created_at, i: row.id });
  return Buffer.from(payload).toString('base64url');
}

function decodeCursor(cursor) {
  return JSON.parse(Buffer.from(cursor, 'base64url').toString());
}

Use a composite sort key (created_at, id) even when sorting by one column — IDs break ties when timestamps collide.

The query becomes:

SELECT * FROM posts
WHERE (created_at, id) < ($t, $i)
ORDER BY created_at DESC, id DESC
LIMIT $first + 1

For ascending sorts, flip the comparison operator.

Resolver implementation

async function postsConnection(_, { first = 20, after }, { db }) {
  const limit = Math.min(first, 100); // cap page size
  let query = db('posts').orderBy([
    { column: 'created_at', order: 'desc' },
    { column: 'id', order: 'desc' },
  ]);

  if (after) {
    const { t, i } = decodeCursor(after);
    query = query.where(function () {
      this.where('created_at', '<', t)
        .orWhere(function () {
          this.where('created_at', '=', t).andWhere('id', '<', i);
        });
    });
  }

  const rows = await query.limit(limit + 1);
  const hasNextPage = rows.length > limit;
  const nodes = hasNextPage ? rows.slice(0, limit) : rows;

  const edges = nodes.map(node => ({
    node,
    cursor: encodeCursor(node),
  }));

  return {
    edges,
    pageInfo: {
      hasNextPage,
      hasPreviousPage: !!after,
      startCursor: edges[0]?.cursor ?? null,
      endCursor: edges[edges.length - 1]?.cursor ?? null,
    },
  };
}

Index requirements

Cursor pagination is only fast with matching indexes. For (created_at DESC, id DESC):

CREATE INDEX idx_posts_cursor ON posts (created_at DESC, id DESC);

Without this index, each page is a sequential scan. Explain-analyze your paginated queries before shipping.

Client usage

Forward infinite scroll:

const { data, fetchMore } = useQuery(POSTS_QUERY, { variables: { first: 20 } });

function loadMore() {
  fetchMore({
    variables: { after: data.posts.pageInfo.endCursor },
    updateQuery: (prev, { fetchMoreResult }) => ({
      posts: {
        ...fetchMoreResult.posts,
        edges: [...prev.posts.edges, ...fetchMoreResult.posts.edges],
      },
    }),
  });
}

Check pageInfo.hasNextPage before calling fetchMore — don't fire requests into an empty tail.

Common mistakes

Mistake Fix
Cursor is just the row ID Encode full sort position
No tiebreaker column Add id to sort and cursor
Unbounded first Cap at 50–100 server-side
COUNT(*) for hasNextPage Fetch N+1 rows instead
Mutable sort keys Sort by immutable columns only

Backward pagination

Relay supports last + before for reverse pagination (chat history, logs):

query {
  messages(last: 20, before: "cursor_xyz") {
    edges { node { id body } cursor }
    pageInfo { hasPreviousPage startCursor }
  }
}

SQL for backward cursor requires reversing sort in query, then reversing results in application layer — easy to get wrong. Many APIs implement forward-only pagination and use separate "load older" endpoint with explicit timestamp cursor.

Null handling in cursors

Nullable sort columns break cursor stability:

-- BAD: NULL created_at rows sort unpredictably
ORDER BY created_at DESC

-- GOOD: COALESCE with sentinel
ORDER BY COALESCE(created_at, '1970-01-01') DESC, id DESC

Encode null presence in cursor payload if sort column is nullable — decoding must reconstruct exact SQL sort semantics.

GraphQL-specific performance

DataLoader batching still applies to Connection resolvers — N+1 on edges.node.author defeats cursor pagination performance gains. Use @defer sparingly on paginated lists; deferred fragments complicate cache keys for APQ.

Load test pagination deep into tail (page 100+) — missing composite index shows up only at depth, not on first page.

Pair with persisted queries security when paginated queries are your highest-traffic operations.

Production checklist

Offset pagination still appears in admin exports — keep cursor APIs for user-facing lists and document when internal tools may use unsafe OFFSET for one-off reports.

Common production mistakes

Teams get pagination cursor relay wrong in predictable ways:

GraphQL APIs for pagination cursor relay melt down under nested queries without depth limits, N+1 resolvers hit the database per field, and schema deprecation has no usage telemetry.

Resources

Frequently asked questions

Why use cursor pagination instead of offset pagination in GraphQL?

Offset pagination (LIMIT/OFFSET) breaks when rows are inserted or deleted during paging — users see duplicates or skip items. Cursor pagination anchors to a stable position (usually a sort key + ID), so pages remain consistent even as the dataset changes. It also scales better because OFFSET scans skip rows expensively on large tables.

What is the Relay Connection spec?

It's a GraphQL pagination convention defining Connection, Edge, and PageInfo types. A Connection wraps a list of Edges (each with a node and cursor), plus PageInfo (hasNextPage, endCursor). Clients pass after/before cursors and first/last counts to page forward or backward.

What should a cursor encode?

Encode an opaque, stable sort key — typically base64(sortValue:id) or an encrypted tuple. Never expose raw database IDs alone if sort order can change. The cursor must uniquely identify a position in the sorted result set, not just a row ID.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →