Skip to content
Codeloom
GraphQL

GraphQL Pagination: Cursor vs Offset with Relay Spec

Compare cursor-based and offset-based pagination in GraphQL, and implement the Relay connection specification for scalable lists.

·7 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • The tradeoffs between offset-based and cursor-based pagination
  • How to implement the Relay connection specification in GraphQL
  • How to handle bidirectional pagination with forward and backward cursors

Prerequisites

  • Basic GraphQL knowledge
  • SQL query fundamentals

Why Pagination Matters in GraphQL

GraphQL lets clients request exactly the data they need, but without pagination, a query like { users { name } } could return millions of records. Pagination is not optional in production APIs. The question is which approach to use.

Offset-Based Pagination

Offset pagination uses limit and offset (or page and pageSize) to specify which slice of data to return:

type Query {
  users(limit: Int = 20, offset: Int = 0): UserPage!
}

type UserPage {
  items: [User!]!
  totalCount: Int!
  hasMore: Boolean!
}

The resolver maps directly to SQL:

const resolvers = {
  Query: {
    users: async (_, { limit, offset }) => {
      const items = await db.query(
        'SELECT * FROM users ORDER BY id LIMIT $1 OFFSET $2',
        [limit, offset]
      );
      const [{ count }] = await db.query('SELECT COUNT(*) FROM users');

      return {
        items,
        totalCount: parseInt(count),
        hasMore: offset + items.length < parseInt(count),
      };
    },
  },
};

Offset Advantages

  • Simple to understand and implement.
  • Supports jumping to any page directly (“go to page 5”).
  • Maps naturally to SQL LIMIT and OFFSET.

Offset Disadvantages

  • Unstable results. If a record is inserted or deleted between page requests, items shift. A user might see the same item twice or miss items entirely.
  • Poor performance at scale. OFFSET 100000 forces the database to scan and discard 100,000 rows before returning results.
  • Not suitable for real-time data. In feeds, notifications, or chat, new items constantly arrive, making offset-based pages unreliable.

Cursor-Based Pagination

Cursor pagination uses an opaque pointer (the cursor) to mark a position in the dataset. Each item has a cursor, and clients request items before or after a specific cursor:

type Query {
  users(first: Int, after: String, last: Int, before: String): UserConnection!
}

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type UserEdge {
  cursor: String!
  node: User!
}

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

How Cursors Work

A cursor encodes the position of an item. The simplest cursor is a base64-encoded ID or timestamp:

function encodeCursor(id) {
  return Buffer.from(`cursor:${id}`).toString('base64');
}

function decodeCursor(cursor) {
  const decoded = Buffer.from(cursor, 'base64').toString('utf-8');
  return decoded.replace('cursor:', '');
}

The resolver uses the decoded cursor to filter results:

const resolvers = {
  Query: {
    users: async (_, { first = 20, after, last, before }) => {
      let query = 'SELECT * FROM users';
      const params = [];

      if (after) {
        const afterId = decodeCursor(after);
        query += ' WHERE id > $1';
        params.push(afterId);
      }

      query += ' ORDER BY id ASC LIMIT $' + (params.length + 1);
      params.push(first + 1); // Fetch one extra to check hasNextPage

      const rows = await db.query(query, params);
      const hasNextPage = rows.length > first;
      const items = hasNextPage ? rows.slice(0, -1) : rows;

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

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

Cursor Advantages

  • Stable results. Insertions and deletions do not cause items to shift between pages.
  • Efficient at any depth. The database uses an index to find the cursor position, avoiding full scans.
  • Natural for real-time data. Cursors work well with feeds and streams where new data arrives constantly.

Cursor Disadvantages

  • Cannot jump to an arbitrary page (“go to page 5”).
  • More complex to implement than offset pagination.
  • Cursors are opaque to clients, making debugging harder.

The Relay Connection Specification

The Relay specification formalizes cursor-based pagination with a standard structure that many GraphQL clients and tools understand. The key components:

Arguments

  • first: Number of items to return from the beginning.
  • after: Cursor to start after (forward pagination).
  • last: Number of items to return from the end.
  • before: Cursor to start before (backward pagination).

Use first/after for forward pagination and last/before for backward pagination. Do not mix them.

Connection Type

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
}

Edge Type

Each edge wraps a node with its cursor:

type UserEdge {
  cursor: String!
  node: User!
}

The edge type can also carry relationship metadata:

type FriendshipEdge {
  cursor: String!
  node: User!
  friendsSince: DateTime!  # Metadata about the relationship
  mutualFriends: Int!
}

PageInfo

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

Implementing Backward Pagination

Backward pagination with last and before reverses the query direction:

async function paginateUsers({ first, after, last, before }) {
  let query, params, isForward;

  if (first != null) {
    isForward = true;
    query = 'SELECT * FROM users';
    params = [];
    if (after) {
      query += ' WHERE id > $1';
      params.push(decodeCursor(after));
    }
    query += ` ORDER BY id ASC LIMIT $${params.length + 1}`;
    params.push(first + 1);
  } else if (last != null) {
    isForward = false;
    query = 'SELECT * FROM users';
    params = [];
    if (before) {
      query += ' WHERE id < $1';
      params.push(decodeCursor(before));
    }
    query += ` ORDER BY id DESC LIMIT $${params.length + 1}`;
    params.push(last + 1);
  }

  let rows = await db.query(query, params);
  const hasMore = rows.length > (first || last);
  rows = hasMore ? rows.slice(0, -1) : rows;

  // Reverse backward results to return in ascending order
  if (!isForward) rows.reverse();

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

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

Compound Cursors

When ordering by non-unique fields like createdAt, you need compound cursors to avoid ambiguity:

function encodeCursor(item) {
  return Buffer.from(
    JSON.stringify({ createdAt: item.createdAt, id: item.id })
  ).toString('base64');
}

function decodeCursor(cursor) {
  return JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8'));
}

// SQL with compound cursor
const { createdAt, id } = decodeCursor(after);
const query = `
  SELECT * FROM posts
  WHERE (created_at, id) > ($1, $2)
  ORDER BY created_at ASC, id ASC
  LIMIT $3
`;

This ensures deterministic ordering even when multiple items share the same timestamp.

When to Use Which

Use offset pagination when you need page numbers (admin dashboards, search results with “page 3 of 10”), the dataset is small and stable, or simplicity is more important than edge cases.

Use cursor pagination when the dataset changes frequently (feeds, notifications), performance at scale matters, you need stable pagination through real-time data, or you are building a Relay-compatible API.

Summary

Offset pagination is simpler but suffers from performance degradation at scale and unstable results in dynamic datasets. Cursor pagination solves both problems by using opaque pointers instead of numeric offsets. The Relay connection specification standardizes cursor pagination with edges, nodes, and PageInfo. For most production GraphQL APIs serving dynamic data, cursor-based pagination is the right choice. Reserve offset pagination for admin interfaces and search results where page jumping is a requirement.