Pagination / Cursors
Pagination is GraphQL’s answer to “don’t return all 10 million records”. The two standard patterns are offset-based (simple, but breaks on inserts) and cursor-based (Relay-style connections, robust at scale). Pick cursors for feeds; offset for static reports.
Offset, cursor, connections, Relay
EXAMPLE
# 1) Offset-based — simple but fragile
type Query {
posts(limit: Int = 20, offset: Int = 0): [Post!]!
postsCount: Int!
}
# Client:
# query { posts(limit: 20, offset: 40) { id title } postsCount }
# Problems:
# • If new posts insert at the top, you SEE DUPLICATES or SKIP
# • Performance: OFFSET 1000000 scans 1M rows
# • Limited expressiveness; clients can't request 'after this id'
# 2) Cursor-based — the Relay Connection spec
type Query {
posts(first: Int, after: String, last: Int, before: String, status: PostStatus): PostConnection!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int # optional; expensive at scale
}
type PostEdge {
cursor: String!
node: Post!
}
type PageInfo {
startCursor: String
endCursor: String
hasNextPage: Boolean!
hasPreviousPage: Boolean!
}
# 3) Cursor encoding
# Cursors are OPAQUE strings — base64-encoded composite of stable sort keys.
#
# For 'sort by createdAt DESC, then id DESC':
# cursor = base64('2024-01-15T03:21:00Z|abc1234')
#
# Server decodes + uses for WHERE clause:
# WHERE (createdAt, id) < (decoded_at, decoded_id)
# 4) Resolver sketch
const resolvers = {
Query: {
posts: async (_p, { first = 20, after, status }, ctx) => {
const decoded = after ? decodeCursor(after) : null;
const where = {
status: status ?? undefined,
...(decoded && {
OR: [
{ createdAt: { lt: decoded.createdAt } },
{ createdAt: decoded.createdAt, id: { lt: decoded.id } },
],
}),
};
const rows = await ctx.db.post.findMany({
where,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: first + 1, // peek for hasNextPage
});
const hasNextPage = rows.length > first;
const items = hasNextPage ? rows.slice(0, -1) : rows;
const edges = items.map((node) => ({
cursor: encodeCursor({ createdAt: node.createdAt, id: node.id }),
node,
}));
return {
edges,
pageInfo: {
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
hasNextPage,
hasPreviousPage: !!after,
},
};
},
},
};
# 5) Client query
# query Feed($cursor: String) {
# posts(first: 20, after: $cursor) {
# edges { cursor node { id title createdAt } }
# pageInfo { hasNextPage endCursor }
# }
# }
#
# Next page: { cursor: result.posts.pageInfo.endCursor }
# Stop when hasNextPage is false.
# 6) Why cursor over offset
# • Stable under inserts/deletes — cursors point at the actual row, not a row count
# • Fast: WHERE on indexed sort key uses btree skip
# • Pageable from any point — bookmark a cursor, return tomorrow
# • Composes with filters — cursor + status + tag all in one query
# 7) totalCount — be careful
# COUNT(*) on large tables is SLOW.
# • Use approximate counts (Postgres: relpages * average_rows_per_page)
# • Or drop totalCount and use 'infinite scroll' UI
# • Or pre-compute via materialised view
# 8) Bidirectional pagination — last + before
# Same idea but in reverse. Less common; many apps support next only.
# 9) Apollo Client auto-pagination
# fetchMore({ variables: { cursor } }) and merge in cache.
# Use field policies for connection-shaped fields:
# posts: {
// keyArgs: ['status'],
// merge(existing, incoming) {
// return {
// ...incoming,
// edges: [...(existing?.edges ?? []), ...incoming.edges],
// };
// },
// }
# 10) Server-side variants
# • Keyset pagination — what we showed; column-based
# • Seek pagination — alias for keyset
# • Page-based with stable sort + tiebreaker — simpler client; slightly less robust
# • Cursor over multiple sort keys — composite cursor (createdAt, id)
# 11) Pagination + filters
# When the filter changes, RESET the cursor. Cursor encodes WHERE, but only for the active query.
# UI: clear scroll position + cursor when status / search changes.
# 12) Pagination + sort order
# Cursor must encode the SORT KEY plus tiebreaker.
# Changing sort = new cursor scheme; reset pagination.
# 13) Infinite scroll vs paged UI
# • Infinite scroll → cursor-based + IntersectionObserver
# • Pages 1, 2, 3, … → offset-based works if data is static
# • 'Load more' button → either pattern; cursor preferred
# 14) Common bugs
# • Using offset without ORDER BY → non-deterministic; ALWAYS sort
# • Offset duplicates / skips when data churns → switch to cursor
# • Cursor includes only one column (createdAt) and ties exist → records skipped; include id as tiebreaker
# • Encoding cursor with PII → leaks info; encrypt or use opaque ids
# • Cursor signed but not verified → tampering allows reading other slices
# • totalCount on huge tables — SLOW; remove or approximate
# • Returning empty edges + hasNextPage: true — clients loop forever
# • Cursor schema changes deploy mid-flight — old cursors become invalid; version cursors
Why it matters
Cursor-based pagination via Relay-style Connections is the production-grade pattern: stable under inserts, fast on indexes, composable with filters. Encode a composite cursor (sort key + id tiebreaker), peek at first + 1 rows to decide hasNextPage, and drop totalCount from large datasets or compute it approximately.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
# Relay cursor pagination
query { users(first: 20, after: "cursor") { edges { node { id } } pageInfo { endCursor hasNextPage } } }
Try it Yourself »
Discussion
Loading…