GraphQL Pagination — Keyset, Offset, and Cursor-Based Pagination Explained
In this tutorial, you will learn about Graphql Pagination. We cover key concepts, practical examples, and best practices to help you master this topic.
Pagination in GraphQL lets clients request slices of a list without fetching the entire dataset, reducing response size and improving performance through limit, offset, cursor, and connection patterns.
What You'll Learn
- Offset-based pagination with limit/skip
- Cursor-based pagination with after/before
- Relay connection specification
- Handling total counts and page info
- Performance considerations for large datasets
Why It Matters
Without pagination, a single query could return thousands of records, overwhelming the server and the client. GraphQL's flexible pagination lets clients request exactly what they need. DodaTech's Durga Antivirus Pro uses cursor-based pagination to load threat logs across 10 million+ devices, ensuring the dashboard loads in under 2 seconds.
Real-World Use
A threat intelligence dashboard shows 50 recent alerts with a "Load More" button. Each page returns the next 50 alerts using cursor pagination. When the user searches by date range, the server applies filters before paginating. The same API serves the mobile app with 10-item pages and the web dashboard with 50-item pages.
flowchart LR
A["Client Request\n{first: 10, after: 'cursor'}"] --> B["GraphQL Server"]
B --> C["DB Query\nWHERE id > cursor\nLIMIT 11"]
C --> D["Response\n{edges: [...],\npageInfo: {hasNextPage}}"]
D --> A
style A fill:#dbeafe,stroke:#2563eb
style C fill:#fef3c7,stroke:#d97706
Code Examples
Example 1: Offset-Based Pagination
type Query {
devices(limit: Int, offset: Int): [Device!]!
}
const resolvers = {
Query: {
devices: async (_, { limit = 20, offset = 0 }, context) => {
const items = await context.db.devices
.find()
.sort({ createdAt: -1 })
.skip(offset)
.limit(limit)
.toArray();
return items;
},
},
};
Example 2: Cursor-Based Pagination
type Query {
devices(first: Int, after: String): DeviceConnection!
}
type DeviceConnection {
edges: [DeviceEdge!]!
pageInfo: PageInfo!
}
type DeviceEdge {
node: Device!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
const resolvers = {
Query: {
devices: async (_, { first = 20, after }, context) => {
const query = {};
if (after) {
const decoded = Buffer.from(after, 'base64').toString();
query._id = { $gt: decoded };
}
const items = await context.db.devices
.find(query)
.sort({ _id: 1 })
.limit(first + 1)
.toArray();
const hasNextPage = items.length > first;
if (hasNextPage) items.pop();
const edges = items.map(item => ({
node: item,
cursor: Buffer.from(item._id.toString()).toString('base64'),
}));
return {
edges,
pageInfo: {
hasNextPage,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor,
},
};
},
},
};
Example 3: Sorting and Filtering with Pagination
type Query {
threats(
first: Int
after: String
severity: Severity
sortBy: ThreatSortField
): ThreatConnection!
}
enum ThreatSortField {
SEVERITY
DETECTED_AT
DEVICE_NAME
}
async function paginatedQuery(params) {
const { first, after, severity, sortBy } = params;
const query = {};
if (severity) query.severity = severity;
const sortField = sortBy === 'SEVERITY' ? 'severity' : 'detectedAt';
const cursor = after
? Buffer.from(after, 'base64').toString()
: null;
if (cursor) {
query._id = { $gt: cursor };
}
const items = await db.threats
.find(query)
.sort({ [sortField]: -1, _id: 1 })
.limit(first + 1)
.toArray();
const hasNext = items.length > first;
if (hasNext) items.pop();
const edges = items.map(t => ({
node: t,
cursor: Buffer.from(t._id.toString()).toString('base64'),
}));
return { edges, pageInfo: { hasNextPage: hasNext, endCursor: edges.at(-1)?.cursor } };
}
Common Mistakes
- Using offset pagination on large datasets — skip+limit becomes slow beyond 10K rows because the database must scan all skipped records.
- Not sorting by a unique field — if sorting by
nameand two devices share a name, cursor pagination may skip or duplicate results. - Exposing raw database IDs as cursors — encode cursors (e.g., base64) to prevent clients from guessing record order or count.
- Forgetting to request one extra record — the standard pattern requests
first + 1records and uses the extra to determinehasNextPage. - Mixing offset and cursor pagination in the same resolver — pick one approach and apply it consistently across all list fields.
Practice Questions
- Why is cursor-based pagination preferred over offset-based for large datasets?
- How does the Relay connection specification structure paginated responses?
- What is the purpose of encoding cursors in base64?
- How does
hasNextPagework without knowing the total count? - When would you still choose offset-based pagination over cursor-based?
Challenge: Write a pagination resolver for threatLogs(first: Int, after: String, severity: Severity): ThreatLogConnection! that supports filtering by severity, sorting by timestamp descending, and returns proper page info with total count included as a non-standard field.
Mini Project
Build a GraphQL API for a logging dashboard with cursor-based pagination on all list endpoints. Include filters for date range, severity, and device ID. Implement both forward (first/after) and backward (last/before) pagination. Add a total count field for UI progress bars.
FAQ
What's Next
Learn about rate limiting for GraphQL APIs
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro