Implementing GraphQL Pagination: Cursor-Based vs Offset-Based
Imagine your online store displays a catalog of products paginated, 20 per page. A user navigates to page 3, and at that moment an admin adds a new product. What happens? Offset pagination shifts all rows — page 3 now shows duplicates from page 2. Sounds familiar? Cursor-based pagination solves this completely: the cursor fixes the position, and no inserts disrupt the selection.
In practice, we use both strategies depending on the task. In this article, we'll dive into the technical implementation of cursor-based (Relay-style) and offset-based pagination, compare their performance, and provide ready code examples.
Problems We Solve
Duplicates with offset. If a user goes to page 3 and a new record is inserted between requests, it shifts all rows — the user sees duplicates. Slow performance with large offset. LIMIT 20 OFFSET 1000000 forces the database to scan a million rows before returning 20 — the time difference can be 100× compared to a cursor query. For example, on a test table with 1 million records, offset with a skip of 500,000 took 2.3 seconds, while a cursor query took 0.02 seconds. Difficulty with arbitrary jumps in cursor. Cursor-based does not support jumping to an arbitrary page — only forward/backward. We address all these scenarios by selecting the optimal strategy for your stack.
Offset Pagination
Suitable for admin tables and lists with rare updates:
type Query {
posts(limit: Int = 20, offset: Int = 0): PostList!
}
type PostList {
items: [Post!]!
total: Int!
limit: Int!
offset: Int!
hasNextPage: Boolean!
}
const resolvers = {
Query: {
posts: async (parent, { limit = 20, offset = 0 }, context) => {
const safeLimit = Math.min(limit, 100)
const [items, total] = await Promise.all([
context.db.query(
'SELECT * FROM posts ORDER BY created_at DESC LIMIT $1 OFFSET $2',
[safeLimit, offset]
),
context.db.queryOne('SELECT COUNT(*) as total FROM posts')
])
return {
items,
total: parseInt(total.total),
limit: safeLimit,
offset,
hasNextPage: offset + safeLimit < parseInt(total.total)
}
}
}
}
Cursor-Based Pagination (Relay Connection)
The Relay standard (see Relay GraphQL Server Specification) is the right choice for infinite scroll and frequently changing data:
type Query {
posts(
first: Int
after: String
last: Int
before: String
filter: PostFilter
): PostConnection!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
// Cursor is 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('utf8')
const match = decoded.match(/^cursor:(.+)$/)
return match ? match[1] : null
}
const resolvers = {
Query: {
posts: async (parent, { first = 20, after, last, before, filter }, context) => {
const limit = Math.min(first || last || 20, 100)
let query = 'SELECT * FROM posts'
const params = []
const conditions = []
if (filter?.authorId) {
params.push(filter.authorId)
conditions.push(`author_id = $${params.length}`)
}
if (after) {
const afterId = decodeCursor(after)
params.push(afterId)
conditions.push(`id < $${params.length}`) // for DESC sort
}
if (before) {
const beforeId = decodeCursor(before)
params.push(beforeId)
conditions.push(`id > $${params.length}`)
}
if (conditions.length) {
query += ' WHERE ' + conditions.join(' AND ')
}
query += ' ORDER BY id DESC'
params.push(limit + 1)
query += ` LIMIT $${params.length}`
const rows = await context.db.query(query, params)
const hasMore = rows.length > limit
const items = hasMore ? rows.slice(0, limit) : rows
const edges = items.map(row => ({
node: row,
cursor: encodeCursor(row.id)
}))
const totalCount = await context.db.queryOne(
'SELECT COUNT(*) FROM posts'
).then(r => parseInt(r.count))
return {
edges,
totalCount,
pageInfo: {
hasNextPage: after ? hasMore : false,
hasPreviousPage: before ? hasMore : false,
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null
}
}
}
}
}
Why Cursor-Based Pagination Is Faster at Large Offsets?
An offset query with OFFSET 1000000 forces the database to read a million rows before returning 20. Cursor-based uses an index seek: WHERE id > last_id — this is O(log N). On a table with 10 million rows, the time difference reaches 100×. Cursor-based saves CPU and I/O resources, which is critical under high load.
How to Choose Between Offset and Cursor Pagination?
The choice depends on the scenario. For example, for a social media feed we use only cursor-based — the user won't notice duplicates when new posts are added. For an admin panel with search by ID, offset is fine because you need page 5 out of 100.
| Scenario | Recommendation |
|---|---|
| Admin panel with page navigation | Offset |
| Infinite scroll feed | Cursor |
| Real-time updates (chat, notifications) | Cursor |
| Export all data (no pagination) | Offset with limit |
| Search with filters | Depends on update frequency |
How to Implement Pagination with Relay Connection?
Step by step:
- Define the Connection type (edges, pageInfo) in the schema.
- In the resolver, fetch one extra record beyond the limit to determine hasNextPage.
- Encode the cursor in base64 (can use ID or composite key).
- On the client, set up fetchMore and field policy for merging.
// Apollo Client — infinite scroll
const { data, fetchMore, loading } = useQuery(GET_POSTS, {
variables: { first: 20 }
})
const loadMore = () => {
const endCursor = data.posts.pageInfo.endCursor
if (!endCursor || !data.posts.pageInfo.hasNextPage) return
fetchMore({
variables: { first: 20, after: endCursor },
updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev
return {
posts: {
...fetchMoreResult.posts,
edges: [
...prev.posts.edges,
...fetchMoreResult.posts.edges
]
}
}
}
})
}
// With Apollo Client 3 — InMemoryCache field policies
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: relayStylePagination(['filter'])
}
}
}
})
Comparison: Offset vs Cursor
| Criteria | Offset | Cursor |
|---|---|---|
| Arbitrary page jump | Yes | No |
| Correctness on insert | No (duplicates/skips) | Yes |
| Sort by any field | Easy | Requires index |
| Infinite scroll | No | Yes |
| Scalability (OFFSET 1M) | Slow | Fast (up to 100×) |
| Implementation | Simpler | More complex |
What's Included in Turnkey Pagination Implementation
Our team provides:
- analysis of your current data schema and strategy selection;
- design of Connection types and resolvers;
- implementation of offset and cursor pagination per Relay standard;
- client-side setup (Apollo Client, field policies);
- load testing (up to 10,000 records, up to 1,000 concurrent requests);
- API documentation in GraphQL Playground;
- delivery of source code and access.
Contact us for a consultation — we'll find the optimal solution for your task. Order pagination implementation, and we guarantee correct operation under any data update scenarios. Our experience includes over 30 projects with GraphQL pagination.
Timeline
Implementation of pagination (offset + cursor Relay Connection) for GraphQL API: 1–2 business days. The cost is calculated individually based on schema complexity. We'll assess your project for free.







