The pagination overview earlier in this course compared offset and cursor approaches. This chapter implements cursors the way most large GraphQL APIs expose them: the Relay connection specification, with edges, node, cursor and pageInfo. After this lesson you will be able to design a connection type, encode cursors, write an efficient paging resolver, and consume the result from a client.
Offset pagination (posts(limit: 20, offset: 40)) has two problems that grow with data size. When rows are inserted or deleted between requests, items shift and the client sees duplicates or gaps. And OFFSET 100000 makes the database scan and discard a hundred thousand rows first.
A cursor is an opaque token that points at a specific item. The client asks for 20 items after that cursor, and the database seeks directly to the position with an indexed WHERE clause. Results are stable and every page costs the same.
The Relay specification defines a consistent structure that clients and tooling understand:
type Query {
posts(first: Int = 20, after: String, last: Int, before: String): PostConnection!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
cursor: String!
node: Post!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}first/after page forward, last/before page backward. Each edge carries its own cursor so a client can resume from any item, and pageInfo tells it whether to show a "load more" control. totalCount is a common addition outside the specification.
A cursor must identify a position in the sort order. For a list sorted by createdAt and then id, both values go into the cursor. Base64-encoding a small JSON string makes the token opaque so clients build no assumptions on its format:
const encodeCursor = (row) =>
Buffer.from(JSON.stringify([row.createdAt.toISOString(), row.id])).toString("base64url");
const decodeCursor = (cursor) => JSON.parse(Buffer.from(cursor, "base64url").toString());Never use an offset number as the cursor; that reintroduces the shifting problem.
Fetch one extra row beyond first to learn whether another page exists without a second query:
posts: async (_p, { first = 20, after }, { db }) => {
const limit = Math.min(first, 100);
const params = [];
let where = "";
if (after) {
const [createdAt, id] = decodeCursor(after);
where = "WHERE (created_at, id) < (?, ?)";
params.push(createdAt, id);
}
const rows = await db.all(
`SELECT * FROM posts ${where} ORDER BY created_at DESC, id DESC LIMIT ?`,
[...params, limit + 1]
);
const page = rows.slice(0, limit);
const edges = page.map((row) => ({ cursor: encodeCursor(row), node: row }));
return {
edges,
pageInfo: {
hasNextPage: rows.length > limit,
hasPreviousPage: Boolean(after),
startCursor: edges[0]?.cursor ?? null,
endCursor: edges.at(-1)?.cursor ?? null,
},
totalCount: (await db.get("SELECT COUNT(*) AS n FROM posts")).n,
};
},The row-value comparison (created_at, id) < (?, ?) uses the same columns as ORDER BY, so a composite index on (created_at, id) serves the query. Clamping first protects the server from a client requesting a million rows.
The client keeps endCursor and sends it back as after:
query Feed($after: String) {
posts(first: 20, after: $after) {
edges { node { id title } }
pageInfo { hasNextPage endCursor }
}
}Apollo Client's relayStylePagination() field policy and Relay merge these pages automatically; with plain fetch, append edges to your list and store the new endCursor.
id) as well as the main column; ties on createdAt otherwise skip or repeat rows.totalCount in its own field resolver so the COUNT(*) only runs when a client selects it.Why is an offset a poor choice as a cursor value?
OFFSET scans.edges { cursor node } plus pageInfo { hasNextPage endCursor }.hasNextPage, and clamp first to a maximum.endCursor back as after.Next lesson: Error Handling Patterns: Codes, Masking and Result Unions — decide which failures are data, which are errors, and how to expose each safely.