GraphQL
Pagination Patterns in GraphQL
Implementing Pagination in GraphQL
Pagination is essential for handling large datasets efficiently. GraphQL supports multiple pagination strategies, from simple offset-based pagination to the more sophisticated Relay-style cursor-based pagination.
Offset-Based Pagination
The simplest pagination approach using limit and offset:
type Query {
posts(limit: Int = 10, offset: Int = 0): PostConnection!
}
type PostConnection {
posts: [Post!]!
total: Int!
hasMore: Boolean!
}
type Post {
id: ID!
title: String!
content: String!
createdAt: DateTime!
}
Offset Pagination Resolver
const resolvers = {
Query: {
posts: async (_, { limit = 10, offset = 0 }) => {
// Validate pagination parameters
if (limit < 1 || limit > 100) {
throw new Error('Limit must be between 1 and 100');
}
if (offset < 0) {
throw new Error('Offset cannot be negative');
}
// Fetch posts with limit and offset
const posts = await Post.find()
.sort({ createdAt: -1 })
.skip(offset)
.limit(limit);
// Get total count for pagination info
const total = await Post.countDocuments();
return {
posts,
total,
hasMore: offset + limit < total
};
}
}
};
// Example query:
// query {
// posts(limit: 20, offset: 40) {
// posts {
// id
// title
// }
// total
// hasMore
// }
// }
Note: Offset pagination is simple but has performance issues with large offsets. Use cursor-based pagination for better performance with large datasets.
Cursor-Based Pagination
More efficient pagination using cursors instead of numeric offsets:
type Query {
posts(
first: Int
after: String
last: Int
before: String
): 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
}
type Post {
id: ID!
title: String!
content: String!
createdAt: DateTime!
}
Cursor Pagination Implementation
const { Buffer } = require('buffer');
// Cursor encoding/decoding
function encodeCursor(value) {
return Buffer.from(value.toString()).toString('base64');
}
function decodeCursor(cursor) {
return Buffer.from(cursor, 'base64').toString('utf-8');
}
const resolvers = {
Query: {
posts: async (_, { first, after, last, before }) => {
// Validate arguments
if (first && last) {
throw new Error('Cannot use first and last together');
}
if (first && first < 1) {
throw new Error('first must be positive');
}
if (last && last < 1) {
throw new Error('last must be positive');
}
// Build query
let query = {};
if (after) {
const afterDate = new Date(decodeCursor(after));
query.createdAt = { $lt: afterDate };
}
if (before) {
const beforeDate = new Date(decodeCursor(before));
query.createdAt = { ...query.createdAt, $gt: beforeDate };
}
// Determine limit and sort order
const limit = first || last || 10;
const sortOrder = last ? 1 : -1;
// Fetch posts
let posts = await Post.find(query)
.sort({ createdAt: sortOrder })
.limit(limit + 1); // Fetch one extra to check for more pages
// Check for more pages
const hasMore = posts.length > limit;
if (hasMore) {
posts = posts.slice(0, limit);
}
// Reverse if fetching last
if (last) {
posts = posts.reverse();
}
// Create edges
const edges = posts.map(post => ({
node: post,
cursor: encodeCursor(post.createdAt.toISOString())
}));
// Get total count
const totalCount = await Post.countDocuments();
return {
edges,
pageInfo: {
hasNextPage: first ? hasMore : false,
hasPreviousPage: last ? hasMore : false,
startCursor: edges.length > 0 ? edges[0].cursor : null,
endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null
},
totalCount
};
}
}
};
Tip: Encode cursors as base64 strings to hide implementation details. Cursors can be based on IDs, timestamps, or composite values.
Relay-Style Connection Pattern
Following the Relay specification for consistent pagination across your API:
// Example Relay query
query GetPosts {
posts(first: 10, after: "Y3JlYXRlZEF0OjIwMjYtMDItMTU=") {
edges {
cursor
node {
id
title
author {
name
}
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}
// Fetching next page
query GetNextPage {
posts(first: 10, after: "Y3JlYXRlZEF0OjIwMjYtMDItMDU=") {
edges {
cursor
node {
id
title
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
// Fetching previous page
query GetPreviousPage {
posts(last: 10, before: "Y3JlYXRlZEF0OjIwMjYtMDItMTU=") {
edges {
cursor
node {
id
title
}
}
pageInfo {
hasPreviousPage
startCursor
}
}
}
Reusable Pagination Helper
// utils/pagination.js
class PaginationHelper {
static encodeCursor(value) {
return Buffer.from(String(value)).toString('base64');
}
static decodeCursor(cursor) {
return Buffer.from(cursor, 'base64').toString('utf-8');
}
static async paginate(model, args, options = {}) {
const {
first,
after,
last,
before,
sortField = 'createdAt',
sortOrder = -1
} = { ...args, ...options };
// Build query
const query = { ...options.where };
if (after) {
const afterValue = this.decodeCursor(after);
query[sortField] = { $lt: afterValue };
}
if (before) {
const beforeValue = this.decodeCursor(before);
query[sortField] = {
...query[sortField],
$gt: beforeValue
};
}
// Determine limit
const limit = first || last || 10;
const sort = last ? -sortOrder : sortOrder;
// Fetch documents
let docs = await model
.find(query)
.sort({ [sortField]: sort })
.limit(limit + 1);
// Check for more pages
const hasMore = docs.length > limit;
if (hasMore) {
docs = docs.slice(0, limit);
}
if (last) {
docs = docs.reverse();
}
// Create edges
const edges = docs.map(doc => ({
node: doc,
cursor: this.encodeCursor(doc[sortField])
}));
// Get total count
const totalCount = await model.countDocuments(options.where || {});
return {
edges,
pageInfo: {
hasNextPage: first ? hasMore : false,
hasPreviousPage: last ? hasMore : false,
startCursor: edges[0]?.cursor || null,
endCursor: edges[edges.length - 1]?.cursor || null
},
totalCount
};
}
}
// Usage in resolver
const resolvers = {
Query: {
posts: async (_, args) => {
return await PaginationHelper.paginate(Post, args);
},
userPosts: async (_, { userId, ...args }) => {
return await PaginationHelper.paginate(Post, args, {
where: { userId }
});
}
}
};
module.exports = PaginationHelper;
Warning: Always limit the maximum page size to prevent performance issues. A common limit is 100 items per page.
Bidirectional Pagination
Supporting both forward and backward pagination:
const resolvers = {
Query: {
posts: async (_, { first, after, last, before }) => {
// Forward pagination
if (first) {
const query = after
? { createdAt: { $lt: decodeCursor(after) } }
: {};
const posts = await Post.find(query)
.sort({ createdAt: -1 })
.limit(first + 1);
const hasNextPage = posts.length > first;
const nodes = hasNextPage ? posts.slice(0, first) : posts;
return {
edges: nodes.map(post => ({
node: post,
cursor: encodeCursor(post.createdAt)
})),
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: nodes[0] ? encodeCursor(nodes[0].createdAt) : null,
endCursor: nodes[nodes.length - 1]
? encodeCursor(nodes[nodes.length - 1].createdAt)
: null
},
totalCount: await Post.countDocuments()
};
}
// Backward pagination
if (last) {
const query = before
? { createdAt: { $gt: decodeCursor(before) } }
: {};
const posts = await Post.find(query)
.sort({ createdAt: 1 })
.limit(last + 1);
const hasPreviousPage = posts.length > last;
const nodes = hasPreviousPage ? posts.slice(0, last) : posts;
nodes.reverse();
return {
edges: nodes.map(post => ({
node: post,
cursor: encodeCursor(post.createdAt)
})),
pageInfo: {
hasNextPage: !!before,
hasPreviousPage,
startCursor: nodes[0] ? encodeCursor(nodes[0].createdAt) : null,
endCursor: nodes[nodes.length - 1]
? encodeCursor(nodes[nodes.length - 1].createdAt)
: null
},
totalCount: await Post.countDocuments()
};
}
throw new Error('Must provide either first or last');
}
}
};
Exercise:
- Implement offset-based pagination for a
usersquery with page size validation - Create a cursor-based pagination system using post IDs instead of timestamps
- Build a reusable pagination helper that works with any Mongoose model
- Implement bidirectional pagination that supports both
first/afterandlast/before - Add filtering capabilities to paginated queries (e.g., filter by category while paginating posts)