Pagination
Cursor-based pagination following the Relay connection specification
You are an expert in GraphQL pagination, helping developers implement cursor-based pagination following the Relay connection specification for scalable, consistent list handling. ## Key Points 1. **Use cursor-based over offset-based pagination** — cursors are stable when items are inserted or deleted, offsets are not. `OFFSET 100` also forces the database to scan 100 rows. 2. **Make cursors opaque** — base64-encode cursors so clients treat them as opaque tokens. This lets you change the underlying implementation without breaking clients. 3. **Always return `pageInfo`** — clients need `hasNextPage` and `endCursor` to know whether and how to fetch more. 4. **Include `totalCount` conditionally** — `COUNT(*)` is expensive on large tables. Consider making it a separate field or caching the result. 5. **Default to reasonable page sizes** — enforce a maximum page size (e.g., 100) to prevent clients from requesting unbounded lists. 6. **Use compound cursors for sort stability** — when sorting by a non-unique column (e.g., `created_at`), include the primary key in the cursor to break ties. - **Using offset pagination disguised as cursors** — encoding an offset as a cursor defeats the purpose. Use keyset pagination (WHERE clause) for true cursor-based pagination. - **Not handling the `last`/`before` arguments** — the Relay spec requires both forward (`first`/`after`) and backward (`last`/`before`) pagination. Many implementations skip backward pagination. - **Unstable sort orders** — if your sort column has duplicate values (e.g., multiple posts with the same `created_at`), pagination can skip or duplicate items. Always include a unique tiebreaker. - **Expensive `totalCount` queries** — running `COUNT(*)` on every paginated request can be a performance bottleneck on large tables. Cache it, approximate it, or make it optional. - **Cursor invalidation** — if the referenced row is deleted, the cursor becomes invalid. Handle this gracefully by falling back to the start of the list or returning an error.
skilldb get graphql-skills/PaginationFull skill: 355 linesPagination — GraphQL
You are an expert in GraphQL pagination, helping developers implement cursor-based pagination following the Relay connection specification for scalable, consistent list handling.
Overview
Pagination in GraphQL is most commonly implemented using the Relay connection specification, which defines a standard structure for paginated lists using cursors, edges, and page info. Unlike offset-based pagination, cursor-based pagination is stable under insertions and deletions and performs well with large datasets.
Core Concepts
Relay Connection Specification
The Relay spec defines a standard shape for paginated fields:
type Query {
posts(first: Int, after: String, last: Int, before: String, filter: PostFilter): 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
}
Cursor Design
Cursors are opaque strings that encode the position in the dataset. Common strategies:
// Base64-encoded primary key — simple and effective
function encodeCursor(id: string): string {
return Buffer.from(`cursor:${id}`).toString("base64");
}
function decodeCursor(cursor: string): string {
const decoded = Buffer.from(cursor, "base64").toString("utf-8");
return decoded.replace("cursor:", "");
}
// Multi-field cursor for complex sorting
function encodeMultiCursor(values: Record<string, string | number>): string {
return Buffer.from(JSON.stringify(values)).toString("base64");
}
function decodeMultiCursor(cursor: string): Record<string, string | number> {
return JSON.parse(Buffer.from(cursor, "base64").toString("utf-8"));
}
Implementation Patterns
Basic Connection Resolver
interface PaginationArgs {
first?: number;
after?: string;
last?: number;
before?: string;
}
async function resolveConnection(
args: PaginationArgs,
queryFn: (params: { limit: number; cursor?: string; direction: "forward" | "backward" }) => Promise<any[]>,
countFn: () => Promise<number>
): Promise<Connection> {
const { first, after, last, before } = args;
// Validate: must provide first OR last, not both
if (first != null && last != null) {
throw new GraphQLError("Cannot use both 'first' and 'last'");
}
if (first != null && first < 0) {
throw new GraphQLError("'first' must be non-negative");
}
if (last != null && last < 0) {
throw new GraphQLError("'last' must be non-negative");
}
const limit = first ?? last ?? 20;
const cursor = after ?? before;
const direction = last != null ? "backward" : "forward";
// Fetch one extra to determine hasNextPage/hasPreviousPage
const rows = await queryFn({ limit: limit + 1, cursor, direction });
const hasExtra = rows.length > limit;
const sliced = hasExtra ? rows.slice(0, limit) : rows;
// For backward pagination, reverse the results
const nodes = direction === "backward" ? sliced.reverse() : sliced;
const edges = nodes.map((node) => ({
node,
cursor: encodeCursor(node.id),
}));
return {
edges,
pageInfo: {
hasNextPage: direction === "forward" ? hasExtra : !!before,
hasPreviousPage: direction === "backward" ? hasExtra : !!after,
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
},
totalCount: await countFn(),
};
}
SQL Implementation with Keyset Pagination
async function getPostsConnection(
db: Database,
args: PaginationArgs & { filter?: PostFilter }
): Promise<PostConnection> {
const { first, after, last, before, filter } = args;
const limit = first ?? last ?? 20;
const direction = last != null ? "backward" : "forward";
let query = db("posts").select("*");
// Apply filters
if (filter?.status) {
query = query.where("status", filter.status);
}
if (filter?.authorId) {
query = query.where("author_id", filter.authorId);
}
// Apply cursor (keyset pagination)
const cursor = after ?? before;
if (cursor) {
const { createdAt, id } = decodeMultiCursor(cursor);
if (direction === "forward") {
query = query.where(function () {
this.where("created_at", "<", createdAt)
.orWhere(function () {
this.where("created_at", "=", createdAt).andWhere("id", "<", id);
});
});
} else {
query = query.where(function () {
this.where("created_at", ">", createdAt)
.orWhere(function () {
this.where("created_at", "=", createdAt).andWhere("id", ">", id);
});
});
}
}
// Order and limit
const order = direction === "forward" ? "desc" : "asc";
query = query.orderBy([
{ column: "created_at", order },
{ column: "id", order },
]).limit(limit + 1);
const rows = await query;
const hasExtra = rows.length > limit;
const sliced = hasExtra ? rows.slice(0, limit) : rows;
const nodes = direction === "backward" ? sliced.reverse() : sliced;
const edges = nodes.map((node) => ({
node,
cursor: encodeMultiCursor({ createdAt: node.created_at, id: node.id }),
}));
// Count query (consider caching for large tables)
const [{ count }] = await db("posts")
.where(filter?.status ? { status: filter.status } : {})
.count("* as count");
return {
edges,
pageInfo: {
hasNextPage: direction === "forward" ? hasExtra : !!before,
hasPreviousPage: direction === "backward" ? hasExtra : !!after,
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
},
totalCount: parseInt(count as string, 10),
};
}
Schema Helpers for Reusable Connections
Generate connection types automatically:
function connectionTypeDefs(typeName: string): string {
return `
type ${typeName}Connection {
edges: [${typeName}Edge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ${typeName}Edge {
node: ${typeName}!
cursor: String!
}
`;
}
// Usage
const typeDefs = `
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
${connectionTypeDefs("Post")}
${connectionTypeDefs("Comment")}
${connectionTypeDefs("User")}
type Query {
posts(first: Int, after: String, last: Int, before: String): PostConnection!
}
`;
Client-Side Pagination with Apollo
const GET_POSTS = gql`
query GetPosts($first: Int!, $after: String) {
posts(first: $first, after: $after) {
edges {
node {
id
title
excerpt
createdAt
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
`;
function PostList() {
const { data, loading, fetchMore } = useQuery(GET_POSTS, {
variables: { first: 20 },
});
const handleLoadMore = () => {
fetchMore({
variables: {
after: data.posts.pageInfo.endCursor,
},
});
};
if (loading && !data) return <Skeleton />;
return (
<div>
<p>{data.posts.totalCount} posts</p>
{data.posts.edges.map(({ node }) => (
<PostCard key={node.id} post={node} />
))}
{data.posts.pageInfo.hasNextPage && (
<button onClick={handleLoadMore}>Load more</button>
)}
</div>
);
}
// Apollo cache merge policy
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
keyArgs: ["filter"], // Separate caches per filter
merge(existing, incoming, { args }) {
if (!args?.after) return incoming; // Fresh query
return {
...incoming,
edges: [...(existing?.edges ?? []), ...incoming.edges],
};
},
},
},
},
},
});
Core Philosophy
Pagination in GraphQL is not a UI concern bolted onto a list endpoint — it is a fundamental data access contract that determines stability, performance, and client-side caching behavior. The Relay connection specification exists because offset-based pagination breaks under real-world conditions: rows are inserted and deleted between page fetches, offsets cause the database to scan and discard rows, and there is no stable reference point for the client to resume from. Cursor-based pagination solves all three problems by anchoring each page to a specific position in the dataset.
The connection model (edges, nodes, cursors, pageInfo) may feel heavy for simple lists, but it provides a uniform contract that every client and every caching layer can depend on. When every paginated field in your schema follows the same shape, client-side infrastructure — infinite scroll components, cache merge policies, prefetch logic — can be written once and reused everywhere. The upfront investment in the connection pattern pays for itself across every list in the application.
Cursors should be treated as opaque tokens by the client and meaningful positions by the server. The client never parses or constructs cursors — it receives them from pageInfo.endCursor and passes them back as the after argument. The server encodes whatever it needs (primary key, sort column values, composite tiebreakers) into the cursor. This contract gives the server freedom to change its pagination strategy without breaking any client.
Anti-Patterns
-
Offset pagination disguised as cursors — base64-encoding an offset number and calling it a "cursor" provides no stability guarantee. If rows are inserted or deleted, offset-based cursors skip or duplicate items. Use keyset (WHERE-clause) pagination for true cursor stability.
-
Unbounded list fields without pagination — a schema field that returns
[Post!]!with no pagination arguments is a latent outage. Any field that can return an unbounded list must acceptfirst/after(or equivalent) arguments and enforce a maximum page size. -
Expensive
totalCounton every page request — runningCOUNT(*)on a large table for every paginated query adds significant latency. MaketotalCounta separate optional field, cache it, or use approximate counts for display purposes. -
Non-unique sort orders without tiebreakers — paginating by
created_atalone fails when multiple rows share the same timestamp. The cursor must include a unique tiebreaker (typically the primary key) to guarantee deterministic ordering across pages. -
Ignoring backward pagination — implementing only
first/afterand skippinglast/beforeviolates the Relay spec and prevents clients from paginating backward. Both directions should be supported, even if backward pagination is less common.
Best Practices
- Use cursor-based over offset-based pagination — cursors are stable when items are inserted or deleted, offsets are not.
OFFSET 100also forces the database to scan 100 rows. - Make cursors opaque — base64-encode cursors so clients treat them as opaque tokens. This lets you change the underlying implementation without breaking clients.
- Always return
pageInfo— clients needhasNextPageandendCursorto know whether and how to fetch more. - Include
totalCountconditionally —COUNT(*)is expensive on large tables. Consider making it a separate field or caching the result. - Default to reasonable page sizes — enforce a maximum page size (e.g., 100) to prevent clients from requesting unbounded lists.
- Use compound cursors for sort stability — when sorting by a non-unique column (e.g.,
created_at), include the primary key in the cursor to break ties.
Common Pitfalls
- Using offset pagination disguised as cursors — encoding an offset as a cursor defeats the purpose. Use keyset pagination (WHERE clause) for true cursor-based pagination.
- Not handling the
last/beforearguments — the Relay spec requires both forward (first/after) and backward (last/before) pagination. Many implementations skip backward pagination. - Unstable sort orders — if your sort column has duplicate values (e.g., multiple posts with the same
created_at), pagination can skip or duplicate items. Always include a unique tiebreaker. - Expensive
totalCountqueries — runningCOUNT(*)on every paginated request can be a performance bottleneck on large tables. Cache it, approximate it, or make it optional. - Cursor invalidation — if the referenced row is deleted, the cursor becomes invalid. Handle this gracefully by falling back to the start of the list or returning an error.
Install this skill directly: skilldb add graphql-skills
Related Skills
Apollo Client
Apollo Client with React for querying, mutating, and caching GraphQL data
Apollo Server
Apollo Server setup, configuration, plugins, and production deployment patterns
Authentication
Authentication and authorization patterns for securing GraphQL APIs
Code Generation
Type-safe GraphQL development with graphql-codegen for TypeScript
Resolvers
Resolver patterns, data loading strategies, and the N+1 problem in GraphQL
Schema Design
Schema design principles for building maintainable, intuitive GraphQL APIs