GraphQL

Pagination Patterns in GraphQL

17 min Lesson 13 of 35

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:
  1. Implement offset-based pagination for a users query with page size validation
  2. Create a cursor-based pagination system using post IDs instead of timestamps
  3. Build a reusable pagination helper that works with any Mongoose model
  4. Implement bidirectional pagination that supports both first/after and last/before
  5. Add filtering capabilities to paginated queries (e.g., filter by category while paginating posts)