Why Pagination Matters
Without pagination, a query like posts { title } could return millions of records, destroying performance and user experience. GraphQL supports several pagination strategies, each with different trade-offs for consistency, performance, and complexity.
Offset-Based Pagination
The simplest approach. Use limit and offset to define a window into the data. Familiar to anyone who has used SQL's LIMIT/OFFSET:
type Query {
posts(limit: Int = 20, offset: Int = 0): PostsResult!
}
type PostsResult {
items: [Post!]!
totalCount: Int!
hasMore: Boolean!
}
# Usage
query GetPosts {
posts(limit: 10, offset: 0) {
items { id title }
totalCount
hasMore
}
}
# Page 2
query GetPostsPage2 {
posts(limit: 10, offset: 10) {
items { id title }
totalCount
hasMore
}
}
// Resolver for offset-based pagination
const resolvers = {
Query: {
posts: async (_: unknown, args: { limit: number; offset: number }, ctx: Context) => {
const [items, totalCount] = await Promise.all([
ctx.prisma.post.findMany({
take: args.limit,
skip: args.offset,
orderBy: { createdAt: 'desc' },
}),
ctx.prisma.post.count(),
]);
return {
items,
totalCount,
hasMore: args.offset + items.length < totalCount,
};
},
},
};
Offset Pagination: Pros and Cons
- Pro: Simple to implement and understand
- Pro: Supports jumping to any page directly
- Con: Inconsistent results if data changes between requests (items can shift or duplicate)
- Con: Performance degrades for large offsets (database must scan and skip rows)
Cursor-Based Pagination
Cursor-based pagination uses an opaque cursor (usually a base64-encoded ID or timestamp) to mark the position in the result set. It provides consistent results even when data changes between requests:
type Query {
posts(first: Int = 20, 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
}
# Usage — first page
query FirstPage {
posts(first: 10) {
edges {
cursor
node { id title publishedAt }
}
pageInfo {
hasNextPage
endCursor
}
}
}
# Next page — use endCursor from previous response
query NextPage {
posts(first: 10, after: "Y3Vyc29yXzEw") {
edges {
cursor
node { id title publishedAt }
}
pageInfo {
hasNextPage
endCursor
}
}
}
// Cursor-based pagination resolver
function encodeCursor(id: string): string {
return Buffer.from(`cursor_${id}`).toString('base64');
}
function decodeCursor(cursor: string): string {
const decoded = Buffer.from(cursor, 'base64').toString('utf-8');
return decoded.replace('cursor_', '');
}
const resolvers = {
Query: {
posts: async (
_: unknown,
args: { first: number; after?: string },
ctx: Context
) => {
const take = Math.min(args.first, 100); // Cap at 100
const where: any = {};
if (args.after) {
const afterId = decodeCursor(args.after);
const afterPost = await ctx.prisma.post.findUnique({
where: { id: afterId },
select: { createdAt: true },
});
if (afterPost) {
where.createdAt = { lt: afterPost.createdAt };
}
}
const posts = await ctx.prisma.post.findMany({
where,
take: take + 1, // Fetch one extra to check hasNextPage
orderBy: { createdAt: 'desc' },
});
const hasNextPage = posts.length > take;
const edges = posts.slice(0, take).map(post => ({
cursor: encodeCursor(post.id),
node: post,
}));
const totalCount = await ctx.prisma.post.count();
return {
edges,
totalCount,
pageInfo: {
hasNextPage,
hasPreviousPage: !!args.after,
startCursor: edges[0]?.cursor || null,
endCursor: edges[edges.length - 1]?.cursor || null,
},
};
},
},
};
React Client Implementation
// Infinite scroll with cursor-based pagination
import { gql, useQuery } from '@apollo/client';
const GET_POSTS = gql`
query GetPosts($first: Int!, $after: String) {
posts(first: $first, after: $after) {
edges {
cursor
node { id title excerpt publishedAt }
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
function InfinitePostList() {
const { data, loading, fetchMore } = useQuery(GET_POSTS, {
variables: { first: 10 },
});
const loadMore = () => {
if (!data?.posts.pageInfo.hasNextPage) return;
fetchMore({
variables: {
after: data.posts.pageInfo.endCursor,
},
updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev;
return {
posts: {
...fetchMoreResult.posts,
edges: [...prev.posts.edges, ...fetchMoreResult.posts.edges],
},
};
},
});
};
return (
<div>
{data?.posts.edges.map(({ node }: any) => (
<article key={node.id} className="p-4 border-b">
<h2 className="font-bold">{node.title}</h2>
<p className="text-gray-600">{node.excerpt}</p>
</article>
))}
{data?.posts.pageInfo.hasNextPage && (
<button onClick={loadMore} disabled={loading} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
{loading ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
Choosing a Pagination Strategy
- Offset: Best for traditional page numbers (page 1, 2, 3) and admin dashboards
- Cursor: Best for infinite scroll, real-time feeds, and large datasets
- Relay Connection: Best when using Relay client or when you need a standardized pagination spec