Pagination Design
This reference covers pagination patterns for GraphQL schemas, with focus on the cursor-based Connection pattern.
Table of Contents
- Pagination Approaches
- Offset vs Cursor
- Connection Pattern
- Building Connections
- Sorting and Filtering
- Performance Considerations
Pagination Approaches
Simple List (No Pagination)
Only use for small, bounded collections:
type User {
# OK: Users typically have few roles
roles: [Role!]!
# OK: Limited enum values
permissions: [Permission!]!
}Offset-Based
Straightforward approach with limitations:
type Query {
posts(offset: Int = 0, limit: Int = 20): [Post!]!
}Cursor-Based (Connection Pattern)
Recommended for most cases:
type Query {
posts(first: Int, after: String): PostConnection!
}Offset vs Cursor
Offset-Based Pagination
type Query {
posts(offset: Int = 0, limit: Int = 20): PostsPage!
}
type PostsPage {
items: [Post!]!
totalCount: Int!
hasMore: Boolean!
}Pros:
- Straightforward to build
- Allows jumping to specific page
- Familiar to REST developers
Cons:
- Inconsistent with real-time data (items shift)
- Poor performance on large offsets
- Duplicate or missing items when data changes
Cursor-Based Pagination
type Query {
posts(first: Int, after: String): PostConnection!
}Pros:
- Stable pagination (cursor points to specific item)
- Efficient for large datasets
- Works well with real-time updates
- Industry standard (Relay specification)
Cons:
- Can't jump to arbitrary page
- Requires more code to build
- Opaque cursors require explanation
Connection Pattern
Relay Connection Specification
The Connection pattern is defined by the Relay specification and widely adopted:
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
}Connection Arguments
| Argument | Purpose |
|---|---|
first |
Number of items from the start |
after |
Cursor to start after (forward pagination) |
last |
Number of items from the end |
before |
Cursor to start before (backward pagination) |
Usage patterns:
- Forward:
first+after - Backward:
last+before - Don't mix forward and backward in same request
Edge Type
Edges contain:
node: The actual itemcursor: Opaque cursor for this item- Additional edge-specific data (optional)
type PostEdge {
node: Post!
cursor: String!
# Edge-specific metadata
addedAt: DateTime
addedBy: User
}PageInfo Type
type PageInfo {
hasNextPage: Boolean! # More items forward?
hasPreviousPage: Boolean! # More items backward?
startCursor: String # Cursor of first item
endCursor: String # Cursor of last item
}Building Connections
Basic Connection Query
type Query {
# Simple connection
posts(first: Int, after: String): PostConnection!
# Connection with filters
userPosts(
userId: ID!
first: Int
after: String
status: PostStatus
): PostConnection!
}Connection for Relationships
type User {
id: ID!
name: String!
# Paginated relationship
posts(first: Int, after: String): PostConnection!
followers(first: Int, after: String): UserConnection!
following(first: Int, after: String): UserConnection!
}Cursor Design
Cursors should be:
- Opaque: Clients shouldn't parse them
- Stable: Same cursor = same position
- Serializable: Usually base64-encoded
// Common cursor strategies:
// 1. Encoded ID (simple)
const cursor = base64(`id:${post.id}`);
// 2. Encoded timestamp + ID (for sorted lists)
const cursor = base64(`${post.createdAt}:${post.id}`);
// 3. Encoded offset (simpler, but less stable)
const cursor = base64(`offset:${index}`);Default Page Size
Always set sensible defaults and limits:
type Query {
posts(
first: Int = 20 # Default page size
after: String
): PostConnection!
}In resolver, enforce maximum:
const resolvers = {
Query: {
posts: (_, { first = 20, after }) => {
const limit = Math.min(first, 100); // Cap at 100
// ...
}
}
};Sorting and Filtering
Sorting Arguments
enum PostOrderField {
CREATED_AT
UPDATED_AT
TITLE
POPULARITY
}
input PostOrder {
field: PostOrderField!
direction: OrderDirection!
}
enum OrderDirection {
ASC
DESC
}
type Query {
posts(
first: Int
after: String
orderBy: PostOrder = { field: CREATED_AT, direction: DESC }
): PostConnection!
}Filtering Arguments
input PostFilter {
status: PostStatus
authorId: ID
createdAfter: DateTime
createdBefore: DateTime
tags: [String!]
}
type Query {
posts(
first: Int
after: String
filter: PostFilter
orderBy: PostOrder
): PostConnection!
}Combined Example
type Query {
posts(
first: Int = 20
after: String
last: Int
before: String
filter: PostFilter
orderBy: PostOrder
): PostConnection!
}
# Usage:
# query {
# posts(
# first: 10
# filter: { status: PUBLISHED, tags: ["graphql"] }
# orderBy: { field: CREATED_AT, direction: DESC }
# ) {
# edges {
# node { id title }
# cursor
# }
# pageInfo {
# hasNextPage
# endCursor
# }
# }
# }Performance Considerations
Avoid COUNT(*) for totalCount
totalCount can be expensive. Options:
- Make it nullable and skip when expensive
- Return an estimate
- Use a separate query for count
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int # Nullable - may not always be computed
}Use Efficient Cursors
Cursor-based pagination should use indexed columns:
-- Efficient: Uses index on created_at
SELECT * FROM posts
WHERE created_at < $cursor_timestamp
ORDER BY created_at DESC
LIMIT 20;
-- Inefficient: Full table scan
SELECT * FROM posts
LIMIT 20 OFFSET 10000;Limit Maximum Page Size
Prevent clients from requesting too many items:
const MAX_PAGE_SIZE = 100;
function resolveConnection(first: number | null) {
const limit = Math.min(first ?? 20, MAX_PAGE_SIZE);
// ...
}Index Cursor Columns
Ensure database indexes exist for cursor columns:
CREATE INDEX idx_posts_created_at ON posts(created_at DESC);
CREATE INDEX idx_posts_author_created ON posts(author_id, created_at DESC);Consider Denormalization
For very large datasets, consider:
- Materialized views
- Denormalized count columns
- Cached aggregations