Pagination and the connection pattern
Offset versus cursor pagination, building a Relay connection, pageInfo semantics, and why stable ordering is not optional.
The connection shape
type BookConnection {
edges: [BookEdge!]!
nodes: [Book!]!
pageInfo: PageInfo!
totalCount: Int
}
type BookEdge {
cursor: String!
node: Book!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}query Page($first: Int!, $after: String) {
books(first: $first, after: $after) {
nodes { id title }
pageInfo { hasNextPage endCursor }
}
}nodesis the convenient shortcut;edgesexists because an edge can carry data about the relationship (a role, a position, a joined-at timestamp).- The cursor is opaque to clients. It usually encodes the sort key plus the id, base64-encoded so nobody parses it.
- Only ask for what you use: computing
totalCountis a full count query on a large table.
⚠️
A non-unique sort key makes cursor pagination silently drop or repeat rows. Always tie-break on a unique column:
order by published_at desc, id desc, and encode both values in the cursor.Implementing a paginator
const encode = ({ createdAt, id }) =>
Buffer.from(createdAt + "|" + id).toString("base64url");
const decode = (cursor) => {
const [createdAt, id] = Buffer.from(cursor, "base64url").toString().split("|");
return { createdAt, id };
};
async function books(_p, { first = 20, after }, ctx) {
const limit = Math.min(first, 100); // hard cap, always
const where = after
? { OR: [
{ createdAt: { lt: decode(after).createdAt } },
{ createdAt: decode(after).createdAt, id: { lt: decode(after).id } },
] }
: {};
const rows = await ctx.db.book.findMany({
where,
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: limit + 1, // one extra row tells us hasNextPage
});
const hasNextPage = rows.length > limit;
const nodes = hasNextPage ? rows.slice(0, limit) : rows;
return {
nodes,
edges: nodes.map((node) => ({ cursor: encode(node), node })),
pageInfo: {
hasNextPage,
hasPreviousPage: Boolean(after),
startCursor: nodes.length ? encode(nodes[0]) : null,
endCursor: nodes.length ? encode(nodes[nodes.length - 1]) : null,
},
};
}| Approach | Stable under inserts | Jump to page | Cost |
|---|---|---|---|
| Offset / limit | No - rows shift | Yes | OFFSET scans skipped rows |
| Cursor / keyset | Yes | No | Index seek, constant per page |
pageInfo.totalCount | n/a | n/a | Extra count query |
Unbounded [Book] | n/a | n/a | The outage everyone eventually has |
Returning a bare list without a limit is the most common GraphQL production incident: one client asks for everything and the server tries to serialise a million nodes. Cap the page size in the resolver, not in the schema documentation.
FAQ
Do I need the full Relay connection shape?
Only if your clients use Relay-style caching or a library that expects it.
nodes plus pageInfo is often enough. What is not optional is a bounded page size and a stable sort order.How do I implement backward pagination?
Accept
last and before, reverse the sort order in the query, fetch last + 1, then reverse the results before building the connection so the client always receives ascending order.Related
Queries, mutations and resolvers DataLoader and solving the N+1 problem
Last refreshed 2026-09-18.