Subchapter 11.3
references/pagination.mdMarkdown7 KBView on GitHub
This reference covers pagination patterns for GraphQL schemas, with focus on the cursor-based Connection pattern.
Only use for small, bounded collections:
type User {
# OK: Users typically have few roles
roles: [Role!]!
# OK: Limited enum values
permissions: [Permission!]!
}Straightforward approach with limitations:
type Query {
posts(offset: Int = 0, limit: Int = 20): [Post!]!
}Recommended for most cases:
type Query {
posts(first: Int, after: String): PostConnection!
}type Query {
posts(offset: Int = 0, limit: Int = 20): PostsPage!
}
type PostsPage {
items: [Post!]!
totalCount: Int!
hasMore: Boolean!
}Pros:
Cons:
type Query {
posts(first: Int, after: String): PostConnection!
}Pros:
Cons:
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
}| 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:
first + afterlast + beforeEdges contain:
node: The actual itemcursor: Opaque cursor for this itemtype PostEdge {
node: Post!
cursor: String!
# Edge-specific metadata
addedAt: DateTime
addedBy: User
}type PageInfo {
hasNextPage: Boolean! # More items forward?
hasPreviousPage: Boolean! # More items backward?
startCursor: String # Cursor of first item
endCursor: String # Cursor of last item
}type Query {
# Simple connection
posts(first: Int, after: String): PostConnection!
# Connection with filters
userPosts(
userId: ID!
first: Int
after: String
status: PostStatus
): PostConnection!
}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!
}Cursors should be:
// 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}`);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
// ...
}
}
};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!
}input PostFilter {
status: PostStatus
authorId: ID
createdAfter: DateTime
createdBefore: DateTime
tags: [String!]
}
type Query {
posts(
first: Int
after: String
filter: PostFilter
orderBy: PostOrder
): PostConnection!
}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
# }
# }
# }totalCount can be expensive. Options:
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int # Nullable - may not always be computed
}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;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);
// ...
}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);For very large datasets, consider: