Episode 12 covers pagination in GraphQL: why pagination matters, offset-based pagination with limit and skip, cursor-based pagination with stable cursors, the Relay Connection Specification with edge, node, and pageInfo, and best practices for page sizes and caching.

Returning an entire data list in one response is a recipe for disaster. Episode 12 covers pagination — the technique of fetching data in controlled chunks — which is a must-have on nearly every list field in a production API.
We'll compare offset-based and cursor-based pagination, dissect the Relay Connection Specification that has become the de facto standard, and then learn its implementation and best practices, including default page sizes and maximum limits.
Without pagination, a list of millions of items is sent all at once: giant responses, an overloaded database, and mobile apps wasting bandwidth. Pagination solves three problems:
An industry rule of thumb: every list field must have pagination, except lists that are guaranteed to be small and bounded.
The simplest pattern: limit sets the page size, skip sets how many items to skip over.
type Query {
posts(limit: Int = 20, skip: Int = 0): [Post!]!
}Query: {
posts: (_, args, ctx) =>
ctx.db.posts.findMany({ take: args.limit, skip: args.skip }),
},Its strengths are simplicity, intuitiveness, and an easy way to find the total item count. Its weakness shows when data changes: when new items are inserted in the middle, the next page shifts — items get skipped or duplicated. For dynamic feeds, this pattern isn't a good fit.
A cursor is a stable position marker within a data set. Instead of computing "skip 20 items", the server receives "start after this item":
type Query {
posts(after: String, first: Int = 20): PostConnection!
}Query: {
posts: async (_, args, ctx) => {
const decoded = args.after ? Buffer.from(args.after, "base64").toString() : null;
const cursor = decoded ? { id: Number(decoded) } : undefined;
const posts = await ctx.db.posts.findMany({
take: args.first + 1,
...(cursor && { cursor, skip: 1 }),
});
return {
edges: posts.slice(0, args.first).map((p) => ({ node: p, cursor: encodeCursor(p.id) })),
pageInfo: { hasNextPage: posts.length > args.first },
};
},
},Notice the key pattern: fetch first + 1 items to determine hasNextPage. The cursor is encoded as base64 from a unique id — this makes the cursor stable: even if new items come in, the next page still starts at the correct position. That's the main advantage of cursors over offsets.
The Relay Connection Specification is a standard from Relay (episode 26) that's now widely used. Its structure consists of:
connection with the fields edges and pageInfo.edge wrapping each item's node and cursor.pageInfo with hasNextPage, hasPreviousPage, plus startCursor and endCursor.type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
posts(first: Int = 20, after: String): PostConnection!
postsBackward(last: Int = 20, before: String): PostConnection!
}Why does the Relay spec matter? Because client frameworks understand this structure universally: infinite scroll, previous buttons, and list refreshes can be built without knowing each server's schema details. Cursors also support forward (after) and backward (before) navigation.
To count total items (for example, for a count badge), run a count query like SELECT count(*) FROM posts separately or store it as a field on the connection. Remember that adding a count can be expensive on large tables — consider caching or estimation.
page and pageSize arguments.const MAX_LIMIT = 100;
const limit = Math.min(args.first ?? 20, MAX_LIMIT);Paginated results are cacheable when the data order is stable. On the client, store the endCursor from the last response for the next request, and keep already-loaded items when fetching a new page. This is the foundation of smooth infinite scroll — the client-side details will be covered in episode 25.
Key takeaways:
first + 1 items to determine hasNextPage.In the next episode, episode 13, you'll learn about authentication with JWT — the concept of authentication versus authorization, JWT structure, implementing login and signup mutations with bcrypt, context-based authentication, refresh token patterns, and Google and GitHub OAuth integration. Securing access to your API begins!