Courses / GraphQL Complete Guide
Lesson 7 of 18
GraphQL Pagination Patterns
Master GraphQL pagination with cursor-based and offset-based approaches, Relay-style connections, and infinite scroll implementation.
What you'll learn
- ✓How offset-based pagination works and where it breaks down
- ✓How cursor-based pagination provides stable results
- ✓How to implement Relay-style connections with edges and pageInfo
- ✓How to build infinite scroll with Apollo Client
- ✓How to choose the right pagination pattern for your use case
Prerequisites
- •Basic GraphQL queries
- •Familiarity with database queries
Why Pagination Matters
Returning every record in a single response is not viable. A query for “all users” might return millions of rows, overwhelming both the server and the client. Pagination breaks large datasets into manageable chunks. GraphQL offers two fundamental approaches: offset-based and cursor-based. Each has tradeoffs that matter for your specific use case.
Offset-Based Pagination
Offset pagination uses limit and offset arguments, mirroring SQL’s LIMIT and OFFSET:
type Query {
posts(limit: Int = 10, offset: Int = 0): PostList!
}
type PostList {
items: [Post!]!
totalCount: Int!
hasMore: Boolean!
}
type Post {
id: ID!
title: String!
body: String!
createdAt: String!
}
const resolvers = {
Query: {
posts: async (_, { limit, offset }, { db }) => {
const [items, totalCount] = await Promise.all([
db.posts.findMany({
take: limit,
skip: offset,
orderBy: { createdAt: 'desc' },
}),
db.posts.count(),
]);
return {
items,
totalCount,
hasMore: offset + items.length < totalCount,
};
},
},
};
The client fetches pages by incrementing offset:
# Page 1
query { posts(limit: 10, offset: 0) { items { id title } totalCount hasMore } }
# Page 2
query { posts(limit: 10, offset: 10) { items { id title } totalCount hasMore } }
# Page 5
query { posts(limit: 10, offset: 40) { items { id title } totalCount hasMore } }
Advantages: Simple to understand and implement. Supports jumping to arbitrary pages (“go to page 5”). Easy to calculate total page count.
Problems: Offset pagination breaks when data changes between requests. If a new post is inserted while the user is on page 1, page 2 will contain a duplicate of the last item from page 1. Deleted items cause skipped records. Large offsets are also slow — the database must count through all skipped rows.
Cursor-Based Pagination
Cursor pagination uses an opaque pointer (cursor) to mark the position in the dataset. Instead of “skip 40 rows,” it says “give me 10 rows after this specific record”:
type Query {
posts(first: Int = 10, after: String): PostConnection!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int
}
type PostEdge {
cursor: String!
node: Post!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
The cursor typically encodes the sort field value. Base64 encoding makes it opaque to clients:
function encodeCursor(value) {
return Buffer.from(JSON.stringify(value)).toString('base64');
}
function decodeCursor(cursor) {
return JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8'));
}
const resolvers = {
Query: {
posts: async (_, { first = 10, after }, { db }) => {
const where = {};
if (after) {
const { createdAt, id } = decodeCursor(after);
where.OR = [
{ createdAt: { lt: new Date(createdAt) } },
{
createdAt: { equals: new Date(createdAt) },
id: { lt: id },
},
];
}
// Fetch one extra to determine hasNextPage
const items = await db.posts.findMany({
where,
take: first + 1,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});
const hasNextPage = items.length > first;
const edges = items.slice(0, first).map((item) => ({
cursor: encodeCursor({ createdAt: item.createdAt, id: item.id }),
node: item,
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges.length > 0 ? edges[0].cursor : null,
endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null,
},
totalCount: await db.posts.count(),
};
},
},
};
The client paginates by passing the endCursor from the previous response:
# First page
query {
posts(first: 10) {
edges {
cursor
node { id title createdAt }
}
pageInfo {
hasNextPage
endCursor
}
}
}
# Next page -- use endCursor from previous response
query {
posts(first: 10, after: "eyJjcmVhdGVkQXQiOiIyMDI2LTA3LTA3VDEwOjAwOjAwLjAwMFoiLCJpZCI6IjQyIn0=") {
edges {
cursor
node { id title createdAt }
}
pageInfo {
hasNextPage
endCursor
}
}
}
Advantages: Stable results even when data changes. No duplicate or skipped records. Efficient database queries using indexed WHERE clauses instead of OFFSET.
Disadvantages: Cannot jump to an arbitrary page. The total page count requires an extra query. More complex to implement.
Bidirectional Cursor Pagination
Support paginating both forward and backward:
type Query {
posts(
first: Int
after: String
last: Int
before: String
): PostConnection!
}
const resolvers = {
Query: {
posts: async (_, { first, after, last, before }, { db }) => {
if (first && last) {
throw new GraphQLError('Cannot use both first and last');
}
const isForward = !!first || !last;
const limit = first || last || 10;
const cursor = after || before;
let where = {};
let orderDirection = isForward ? 'desc' : 'asc';
if (cursor) {
const decoded = decodeCursor(cursor);
const operator = isForward ? 'lt' : 'gt';
where.OR = [
{ createdAt: { [operator]: new Date(decoded.createdAt) } },
{
createdAt: { equals: new Date(decoded.createdAt) },
id: { [operator]: decoded.id },
},
];
}
let items = await db.posts.findMany({
where,
take: limit + 1,
orderBy: [
{ createdAt: orderDirection },
{ id: orderDirection },
],
});
const hasMore = items.length > limit;
items = items.slice(0, limit);
// Reverse results for backward pagination so order is consistent
if (!isForward) {
items.reverse();
}
const edges = items.map((item) => ({
cursor: encodeCursor({ createdAt: item.createdAt, id: item.id }),
node: item,
}));
return {
edges,
pageInfo: {
hasNextPage: isForward ? hasMore : !!before,
hasPreviousPage: isForward ? !!after : hasMore,
startCursor: edges[0]?.cursor || null,
endCursor: edges[edges.length - 1]?.cursor || null,
},
};
},
},
};
Implementing Infinite Scroll with Apollo Client
Infinite scroll loads more data as the user scrolls down. Combine fetchMore with cursor pagination:
import { useQuery, gql } from '@apollo/client';
import { useRef, useCallback, useEffect } from 'react';
const GET_POSTS = gql`
query GetPosts($first: Int!, $after: String) {
posts(first: $first, after: $after) {
edges {
cursor
node {
id
title
body
createdAt
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
function PostFeed() {
const { data, loading, fetchMore } = useQuery(GET_POSTS, {
variables: { first: 10 },
});
const observer = useRef(null);
const lastPostRef = useCallback(
(node) => {
if (loading) return;
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver((entries) => {
if (
entries[0].isIntersecting &&
data?.posts.pageInfo.hasNextPage
) {
fetchMore({
variables: {
first: 10,
after: data.posts.pageInfo.endCursor,
},
});
}
});
if (node) observer.current.observe(node);
},
[loading, data, fetchMore]
);
if (!data) return <p>Loading...</p>;
return (
<div>
{data.posts.edges.map((edge, index) => {
const isLast = index === data.posts.edges.length - 1;
return (
<article
key={edge.node.id}
ref={isLast ? lastPostRef : null}
>
<h2>{edge.node.title}</h2>
<p>{edge.node.body}</p>
</article>
);
})}
{loading && <p>Loading more...</p>}
</div>
);
}
Configure the cache to merge paginated results:
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
keyArgs: false, // All posts queries share the same cache entry
merge(existing, incoming) {
if (!existing) return incoming;
return {
...incoming,
edges: [...existing.edges, ...incoming.edges],
};
},
},
},
},
},
});
Numbered Page Navigation
If you need traditional page numbers (page 1, 2, 3…) while using cursors internally, precompute cursors for page boundaries:
type Query {
posts(page: Int = 1, pageSize: Int = 10): PaginatedPosts!
}
type PaginatedPosts {
items: [Post!]!
currentPage: Int!
totalPages: Int!
totalCount: Int!
}
const resolvers = {
Query: {
posts: async (_, { page, pageSize }, { db }) => {
const offset = (page - 1) * pageSize;
const [items, totalCount] = await Promise.all([
db.posts.findMany({
skip: offset,
take: pageSize,
orderBy: { createdAt: 'desc' },
}),
db.posts.count(),
]);
return {
items,
currentPage: page,
totalPages: Math.ceil(totalCount / pageSize),
totalCount,
};
},
},
};
This uses offset under the hood, which is acceptable when data changes infrequently and the dataset is small enough that large offsets are not a performance problem.
Filtering with Pagination
Combine filters with cursor pagination by including filter values in keyArgs:
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
keyArgs: ['category', 'authorId', 'sortBy'],
merge(existing, incoming) {
if (!existing) return incoming;
return {
...incoming,
edges: [...existing.edges, ...incoming.edges],
};
},
},
},
},
},
});
query {
posts(
first: 10
after: $cursor
category: "TECH"
authorId: "42"
sortBy: NEWEST
) {
edges {
node { id title }
}
pageInfo { hasNextPage endCursor }
}
}
The keyArgs configuration tells Apollo to maintain separate caches for different filter combinations. Posts filtered by “TECH” are cached separately from posts filtered by “SPORTS.”
Choosing the Right Pattern
| Pattern | Best For | Avoid When |
|---|---|---|
| Offset | Admin panels, small datasets, page number navigation | Real-time feeds, large datasets, frequently changing data |
| Cursor (forward only) | Feeds, timelines, infinite scroll | Need to jump to arbitrary pages |
| Cursor (bidirectional) | Chat history, log viewers, any scroll-back scenario | Simple lists where forward-only suffices |
| Relay connections | Standardized API, codegen compatibility, relay clients | Quick prototypes, simple use cases |
Start with cursor-based Relay connections if you are building a new API. The upfront cost is minimal, the pattern is standardized, and it scales gracefully. Use offset pagination only when you specifically need numbered page navigation and can accept its limitations.
Progress is saved locally to your browser.