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.
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
LIMITandOFFSET.
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 100000forces 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.
Related articles
- GraphQL GraphQL Pagination: Relay-Style Connections
Understand cursor-based pagination using the Relay connection spec, why it scales better than offsets, and how to implement it cleanly in your schema.
- GraphQL Authentication and Authorization in GraphQL
Implement auth in GraphQL using context-based authentication, custom directives, and field-level authorization patterns.
- GraphQL GraphQL Code Generation with TypeScript
Automate type-safe GraphQL development using graphql-codegen to generate TypeScript types, hooks, and resolvers from your schema.
- GraphQL GraphQL Error Handling Patterns and Extensions
Implement robust error handling in GraphQL with error extensions, union-based errors, and structured error responses for better client DX.