Cursor-based Pagination with GraphQL Relay Connections
Implement Relay-style cursor pagination in GraphQL with edges, nodes, and pageInfo for efficient forward and backward traversal
The Relay Connection specification is the de facto standard for paginating GraphQL results. It models collections as connections containing edges, where each edge wraps a node and a cursor. This structure supports stable pagination across inserts and deletes, unlike offset-based approaches that skip or duplicate rows when data changes between requests.
When to Use This
- Collections that grow over time and need stable pagination
- Clients that support infinite scroll or “load more” patterns
- APIs consumed by Relay, Apollo, or any client expecting cursor-based navigation
Prerequisites
- A GraphQL server (Apollo Server, GraphQL Yoga, or similar)
- A data source with a sortable, unique column (ID, timestamp, or cursor)
Solution
1. Define the Connection Types
// schema.ts
import gql from 'graphql-tag';
export const typeDefs = gql`
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type PostEdge {
cursor: String!
node: Post!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type Post {
id: ID!
title: String!
content: String!
createdAt: String!
}
input PaginationInput {
first: Int
after: String
last: Int
before: String
}
type Query {
posts(pagination: PaginationInput): PostConnection!
}
`;
2. Implement Cursor Encoding
// cursor.ts
export function encodeCursor(value: string | number): string {
return Buffer.from(String(value)).toString('base64');
}
export function decodeCursor(cursor: string): string {
return Buffer.from(cursor, 'base64').toString('utf8');
}
3. Build the Resolver
// resolvers.ts
import { encodeCursor, decodeCursor } from './cursor';
interface Post {
id: string;
title: string;
content: string;
createdAt: string;
}
export const postResolvers = {
Query: {
posts: async (
_: unknown,
{ pagination }: { pagination: { first?: number; after?: string; last?: number; before?: string } },
context: { db: { posts: { findMany: (opts: any) => Promise<Post[]>; count: () => Promise<number> } } }
) => {
const { first, after, last, before } = pagination;
const limit = first ?? last ?? 10;
const maxLimit = 50;
const take = Math.min(limit, maxLimit);
let cursor: string | undefined;
let skip = 0;
let order: 'asc' | 'desc' = 'desc';
if (after) {
cursor = decodeCursor(after);
skip = 1;
} else if (before) {
cursor = decodeCursor(before);
skip = 1;
order = 'asc';
}
const posts = await context.db.posts.findMany({
take: take + 1,
skip,
cursor: cursor ? { id: cursor } : undefined,
orderBy: { id: order },
});
const hasMore = posts.length > take;
const trimmed = hasMore ? posts.slice(0, take) : posts;
const reversed = last ? trimmed.reverse() : trimmed;
const edges = reversed.map((post) => ({
cursor: encodeCursor(post.id),
node: post,
}));
const totalCount = await context.db.posts.count();
return {
edges,
totalCount,
pageInfo: {
hasNextPage: Boolean(first && hasMore),
hasPreviousPage: Boolean(after),
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
},
};
},
},
};
4. Query the Connection
query GetPosts($first: Int, $after: String) {
posts(pagination: { first: $first, after: $after }) {
edges {
cursor
node {
id
title
createdAt
}
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
Pass the endCursor from the previous response as after in the next request to load the next page.
How It Works
- Cursors are opaque tokens encoding the last-seen position (typically the row ID). Clients treat them as black boxes.
- Edges pair each node with its cursor, so clients can navigate from any point without tracking offsets.
first+afterfetches forward;last+beforefetches backward. The resolver reverses results when paginating backward.take + 1is a trick to check for a next page without a separate count query — if you get more rows than requested,hasNextPageis true.
Variants
Offset-Based Fallback
For data sources without cursor support (Elasticsearch aggregations, legacy APIs), fall back to offset pagination but still return the connection shape for client compatibility:
const offset = after ? parseInt(decodeCursor(after), 10) + 1 : 0;
const posts = await db.posts.findMany({ skip: offset, take });
Keyset Pagination with Composite Cursors
For ordered columns that aren’t unique (like createdAt), use a composite cursor (createdAt, id) to avoid skipping rows with identical timestamps:
export function encodeCompositeCursor(createdAt: string, id: string): string {
return Buffer.from(`${createdAt}|${id}`).toString('base64');
}
Advanced: Pagination with Sort Order
When paginating by a non-id column (e.g., createdAt), encode the sort value in the cursor:
export function encodeSortCursor(value: string, id: string): string {
return Buffer.from(`${value}|${id}`).toString('base64');
}
export function decodeSortCursor(cursor: string): { value: string; id: string } {
const [value, id] = Buffer.from(cursor, 'base64').toString().split('|');
return { value, id };
}
The resolver uses a compound WHERE clause to skip rows seen in the previous page:
const { value, id } = after ? decodeSortCursor(after) : { value: null, id: null };
const posts = await db.posts.findMany({
where: value
? {
OR: [
{ createdAt: { lt: new Date(value) } },
{ createdAt: new Date(value), id: { lt: id } },
],
}
: undefined,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: take + 1,
});
This handles rows with identical createdAt values without skipping or duplicating them.
Best Practices
-
For a deeper guide, see Complete Guide to GraphQL Federation.
-
Cap
firstandlastat a reasonable maximum (50–100) to prevent expensive queries -
Always sort by a stable column — sorting by non-unique fields without a tiebreaker causes skipped rows
-
Keep cursors opaque — don’t expose internal structures that clients might try to parse
-
Include
totalCountonly when the client needs it; it can be expensive on large tables
Common Mistakes
- Using offset as cursor — this defeats the purpose of cursor pagination and reintroduces skip/duplicate issues
- Forgetting
skip: 1after a cursor — without it, the first item of each page repeats the last item of the previous page - Not handling empty results — return an empty
edgesarray withhasNextPage: falseinstead of throwing
Troubleshooting
- Query returns null unexpectedly: verify resolvers, data loaders, and authorization.
- N+1 query performance issue: use DataLoader or equivalent batching. Inspect resolver execution traces.
- Introspection disabled in production breaks tools: enable it only in development, or use schema artifacts in CI.
- Mutation input rejected: confirm input validation, custom scalars, and whether variables are passed as the right type.
- Subscription stops receiving events: check the pub/sub backend, event filtering, and that the resolver is emitting events.
Further Reading
- Official documentation: check the current reference for the framework or tool used.
- Related guides: explore the graphql and pagination guides for deeper coverage.
- Complementary patterns: review design patterns applicable to your technology stack.
- Public postmortems: study real incidents from teams that faced similar production issues.
Production Notes
- Deploy gradually using canary or blue-green to catch regressions early.
- Configure alerts for error rate, p99 latency, and failure rate before enabling in production.
- Document the rollback in the runbook; test the procedure in staging at least once per quarter.
- Review structured logs with correlation IDs to trace requests end-to-end during incidents.
Key Takeaways
- Apply cursor-based pagination with graphql relay connections when you need a practical solution for your use case.
- Monitor performance after implementation; measure latency, errors, and resource usage before and after.
- Check the Troubleshooting section for common failures; most have documented root causes with fixes.
- Keep dependencies updated and run tests in CI to prevent production regressions.
Common Production Pitfalls
- Copying the example without adapting it to real data volumes and failure modes.
- Skipping load and error-injection tests before the first production deployment.
- Hard-coding values that should be configurable per environment.
- Forgetting to add logging and monitoring at each step.
- Deploying without a rollback plan or a tested backup strategy.
- Assuming the minimal example will scale without adding caching or batching.
- Not documenting the version and configuration used in production.
- Letting the recipe sit unchanged when dependencies or scale evolve.
Frequently Asked Questions
Should I use cursor or offset pagination for GraphQL?
Cursor pagination is the standard for GraphQL because it handles inserts and deletes gracefully. Use offset only when cursors aren't supported by the data source.
How do I implement bidirectional pagination?
Support both first/after and last/before in your resolver. The hasPreviousPage flag tells clients whether a previous page exists.
Can I use Relay connections without the Relay client?
Yes. The connection spec works with any GraphQL client. Apollo Client, urql, and graphql-request all support it.
What should the cursor encode?
Typically the primary key or a composite of the sort column plus primary key. Avoid encoding offsets.
Related Resources
Build a GraphQL API with Apollo Server and TypeScript
How to build a production-ready GraphQL API using Apollo Server, TypeScript, and DataLoader to solve the N+1 query problem
RecipeCursor-Based Pagination with PostgreSQL
Implement efficient cursor-based pagination for large datasets in PostgreSQL, avoiding OFFSET performance degradation with indexed keyset pagination and stable sort ordering
PatternGraphQL Batched Resolver Pattern
Resolve nested GraphQL fields in a single batched request to eliminate N+1 queries and reduce database load.
RecipeBatch and Cache Database Queries with GraphQL DataLoader
Use DataLoader to coalesce individual load requests into batched database calls, solving the N+1 query problem in GraphQL resolvers
PatternGraphQL Connection Pagination Pattern
Implement Relay-style cursor-based pagination with edges, nodes, and pageInfo for stable GraphQL list queries.