Lesson 31 / 34
Pagination Patterns
Building the cursor-based connection model, separating edge from node, the measured behavior difference between offset and cursor pagination while a list changes, and pagination multiplying across nested lists.
Contents
The previous lesson’s query returned all fifty loan records in a single response. The library’s real loan count is in the hundreds of thousands, and no client wants all of them.
Pagination was handled in three ways in the Resource and Contract Design topic, but the design there was built on address and query parameters: what got paginated was an endpoint. In the query-based approach, what gets paginated is a field, and it recurs in every nested list. This lesson builds the standardized form of field-level pagination.
The parser, flattener, and engine are used exactly as they were written in the previous lessons; this lesson adds connection types to the schema.
The Connection Model
A paginated field returns not a list but a connection. A connection has three parts.
- Edges hold a record for every item on the page, and each carries two things: the item itself (node) and a cursor marking that item’s position in the list.
- Page info reports the navigation state: whether there is a page ahead or behind, and the page’s start and end cursors.
- Fields specific to the connection, such as total count, are optional.
Separating edge from node looks like an unnecessary layer at first glance. The reason is this: a cursor is not a property of the item, it is a property of the item’s position in this particular list. The same loan record gets different cursors in two separate lists; if the cursor were placed inside the node, this would be impossible. That same place can also carry other relationship-specific data — the moment the record was added to the list, its ranking score — and none of these are fields of the loan record itself.
// pagination.mjs — cursor-based pagination; the cursor carries the sort key // Records are ordered by the (createdAt, id) pair; the cursor is that pair, encoded. export const encodeCursor = (k) => Buffer.from(`${k.createdAt}|${k.id}`).toString("base64url"); export const decodeCursor = (i) => { const [createdAt, id] = Buffer.from(i, "base64url").toString().split("|"); return { createdAt, id }; }; const compare = (a, b) => (a.createdAt === b.createdAt ? a.id.localeCompare(b.id) : a.createdAt.localeCompare(b.createdAt)); // Cursor-based: the first N records greater than the "after" cursor export function cursorPage(records, { first = 10, after = null }) { const sorted = [...records].sort(compare); const start = after ? sorted.findIndex((k) => compare(k, decodeCursor(after)) > 0) : 0; const slice = start < 0 ? [] : sorted.slice(start, start + first); return { edges: slice.map((k) => ({ cursor: encodeCursor(k), node: k })), pageInfo: { hasNextPage: start >= 0 && start + first < sorted.length, hasPreviousPage: start > 0, startCursor: slice.length ? encodeCursor(slice[0]) : null, endCursor: slice.length ? encodeCursor(slice.at(-1)) : null, }, totalCount: sorted.length, }; } // Offset-based: start at the Nth record in sort order export function offsetPage(records, { first = 10, offset = 0 }) { const sorted = [...records].sort(compare); return { records: sorted.slice(offset, offset + first), totalCount: sorted.length }; }
The cursor being encoded is not obfuscation, it is a contract decision. The client should not decode the cursor, only send it back; the encoding makes visible that its content is not part of the contract. When the sort key changes, the cursor’s structure changes with it, and this does not affect clients that never tried to parse the cursor.
While the List Changes
The difference between offset and cursor is invisible while the list is static. It shows up when a record is inserted or deleted between pages.
// drift.mjs — offset and cursor behavior when a record is inserted/deleted between pages import { cursorPage, offsetPage } from "./pagination.mjs"; const RECORDS = Array.from({ length: 9 }, (_, i) => ({ id: `O-${String(i + 1).padStart(2, "0")}`, createdAt: `2026-01-${String(i + 1).padStart(2, "0")}` })); const PAGE_SIZE = 3; const CHANGES = { "insert at start": (v) => v.push({ id: "O-00", createdAt: "2026-01-00" }), "delete from start": (v) => v.splice(v.findIndex((k) => k.id === "O-01"), 1), }; const FORMATS = { offset: { start: { offset: 0 }, get: (v, d) => offsetPage(v, { first: PAGE_SIZE, offset: d.offset }), ids: (s) => s.records.map((k) => k.id), next: (s, d) => ({ offset: d.offset + PAGE_SIZE }) }, cursor: { start: { after: null }, get: (v, d) => cursorPage(v, { first: PAGE_SIZE, after: d.after }), ids: (s) => s.edges.map((k) => k.node.id), next: (s) => ({ after: s.pageInfo.endCursor }) }, }; for (const [changeName, change] of Object.entries(CHANGES)) { console.log(`after reading the first page: ${changeName} (page size ${PAGE_SIZE}, starting with 9 records)`); for (const [formatName, f] of Object.entries(FORMATS)) { const data = [...RECORDS]; const seen = []; let state = f.start; for (let page = 0; page < 4; page++) { const s = f.get(data, state); const ids = f.ids(s); if (!ids.length) break; seen.push(...ids); if (page === 0) change(data); state = f.next(s, state); } const unique = new Set(seen); const skipped = RECORDS.map((k) => k.id).filter((id) => !unique.has(id) && data.some((k) => k.id === id)); console.log(` ${formatName}: ${seen.join(" ")}`); console.log(` ${formatName}: seen=${seen.length} unique=${unique.size} repeated=${seen.length - unique.size} skipped=${skipped.join(",") || "none"}`); } console.log(); }
after reading the first page: insert at start (page size 3, starting with 9 records) offset: O-01 O-02 O-03 O-03 O-04 O-05 O-06 O-07 O-08 O-09 offset: seen=10 unique=9 repeated=1 skipped=none cursor: O-01 O-02 O-03 O-04 O-05 O-06 O-07 O-08 O-09 cursor: seen=9 unique=9 repeated=0 skipped=none after reading the first page: delete from start (page size 3, starting with 9 records) offset: O-01 O-02 O-03 O-05 O-06 O-07 O-08 O-09 offset: seen=8 unique=8 repeated=0 skipped=O-04 cursor: O-01 O-02 O-03 O-04 O-05 O-06 O-07 O-08 O-09 cursor: seen=9 unique=9 repeated=0 skipped=none
Offset gets it wrong in two separate ways. When a record is inserted into the list,
everything shifts by one position, and the second page shows the first page’s last
record again; O-03 was seen twice. When a record is deleted, the shift runs the other
way and a record is never seen at all; O-04 was skipped.
Cursor navigated correctly in both cases. The reason is that the question being asked is different: offset asks “give me starting from the sixth record from the top,” and it points to the wrong place once the top moves; cursor asks “give me what comes after this record,” and it gets the right answer as long as that record stays put.
A skipped record costs more than a repeated one. A repeat is visible to the user and can be corrected; a skip is silent. If an export job is paginated with offset, a file that ends up missing a record produces no error at all.
Running the Connection
A connection can only be asked for if it is defined in the schema. Edge, page info, and the connection itself are ordinary object types; there is nothing privileged about them. The type record from the Schema and Type System lesson stays as it was, with an extension placed on top that adds these three types and two fields that both return a connection.
// schema-connection.mjs — the schema from lesson 01 with connection types added import { SCHEMA } from "./schema.mjs"; const CONNECTION_FIELD = { type: "LoanConnection!", args: { first: "Int", after: "String" } }; export const SCHEMA_C = { ...SCHEMA, types: { ...SCHEMA.types, Boolean: { kind: "scalar" }, PageInfo: { kind: "object", fields: { hasNextPage: "Boolean!", hasPreviousPage: "Boolean!", startCursor: "String", endCursor: "String" } }, LoanEdge: { kind: "object", fields: { cursor: "String!", node: "Loan!" } }, LoanConnection: { kind: "object", fields: { edges: "[LoanEdge!]!", pageInfo: "PageInfo!", totalCount: "Int!" } }, Member: { ...SCHEMA.types.Member, fields: { ...SCHEMA.types.Member.fields, loanConnection: CONNECTION_FIELD } }, Query: { ...SCHEMA.types.Query, fields: { ...SCHEMA.types.Query.fields, loanConnection: CONNECTION_FIELD } }, }, };
The data source holds nine loan records and three members. The connection resolvers do
not paginate on their own; they hand the records to the cursor page function inside
pagination.mjs and return the resulting structure as is. No resolver has been written
for the edge, page info, or total count fields, because the returned structure already
carries these names.
// data.mjs — nine loan records, three members, and connection resolvers import { cursorPage } from "./pagination.mjs"; export const MEMBERS = new Map(Array.from({ length: 3 }, (_, i) => [`U-${1001 + i}`, { id: `U-${1001 + i}`, createdAt: "2025-01-01", name: `Member ${i}` }])); export const LOANS = Array.from({ length: 9 }, (_, i) => ({ id: `O-${String(i + 1).padStart(2, "0")}`, createdAt: `2026-01-${String(i + 1).padStart(2, "0")}`, memberId: `U-${1001 + (i % 3)}`, status: i % 3 === 0 ? "CLOSED" : "OPEN", returnDate: "2026-04-01", items: [{ isbn: "978-0262033848", branch: "central" }], })); const paginate = (records, a) => cursorPage(records, { first: a.first ?? 10, after: a.after ?? null }); export const RESOLVERS = { __type: { Record: (d) => (d.items ? "Loan" : "Member") }, Query: { member: (_, a) => MEMBERS.get(a.code) ?? null, loanConnection: (_, a) => paginate(LOANS, a), }, Member: { loanConnection: (u, a) => paginate(LOANS.filter((o) => o.memberId === u.id), a) }, Loan: { member: (o) => MEMBERS.get(o.memberId) }, };
// connection.mjs — runs the connection model; measures nested pagination import { parse } from "./parser.mjs"; import { execute } from "./executor.mjs"; import { SCHEMA_C as SCHEMA } from "./schema-connection.mjs"; import { RESOLVERS } from "./data.mjs"; const PAGE = `query($first: Int!, $after: String) { loanConnection(first: $first, after: $after) { totalCount pageInfo { hasNextPage hasPreviousPage endCursor } edges { cursor node { id status } } } }`; // Walk every page by following the cursor let after = null, page = 0, totalEdges = 0; while (true) { const s = await execute(SCHEMA, RESOLVERS, parse(PAGE), { variables: { first: 4, after } }); const b = s.data.loanConnection; totalEdges += b.edges.length; console.log(`page ${++page}: ${b.edges.map((k) => k.node.id).join(" ")} next=${b.pageInfo.hasNextPage} previous=${b.pageInfo.hasPreviousPage}`); if (!b.pageInfo.hasNextPage) { console.log(`total records=${b.totalCount} edges walked=${totalEdges}`); break; } after = b.pageInfo.endCursor; } console.log(`\nfirst page's first edge: ${JSON.stringify((await execute(SCHEMA, RESOLVERS, parse(PAGE), { variables: { first: 1, after: null } })).data.loanConnection.edges[0])}`); // Nested pagination: each member's own loan connection is paginated separately const NESTED = `{ m1: member(code: "U-1001") { name loanConnection(first: 2) { totalCount edges { node { id } } } } m2: member(code: "U-1002") { name loanConnection(first: 2) { totalCount edges { node { id } } } } }`; const nested = (await execute(SCHEMA, RESOLVERS, parse(NESTED))).data; for (const [key, m] of Object.entries(nested)) { console.log(`${key} ${m.name}: ${m.loanConnection.edges.map((k) => k.node.id).join(" ")} (total ${m.loanConnection.totalCount})`); }
page 1: O-01 O-02 O-03 O-04 next=true previous=false
page 2: O-05 O-06 O-07 O-08 next=true previous=true
page 3: O-09 next=false previous=true
total records=9 edges walked=9
first page's first edge: {"cursor":"MjAyNi0wMS0wMXxPLTAx","node":{"id":"O-01","status":"CLOSED"}}
m1 Member 0: O-01 O-04 (total 3)
m2 Member 1: O-02 O-05 (total 3)
Nine edges were walked across three pages; no repeats, no skips. The cursor value appears in its encoded form, and it is clear from this that its content is not meant to be read.
The last two lines show where query-based pagination diverges from resource-based
pagination. The two members’ loan connections were paginated separately; each has
its own totalCount value, its own edges. In an endpoint design, this would mean two
separate requests; here, it is two branches of a single query.
The consequence is that pagination is no longer a single boundary. If first: 20
members are requested from the root field and each member’s first: 20 loans are
requested, the response carries four hundred loan records. Every field respects its own
limit, and yet the total comes out larger than expected even though all of them do.
Pagination alone does not bound the size of the response.
Summary
- A paginated field returns a connection, not a list; a connection is made up of edges, page info, and fields specific to the connection.
- A cursor is a property of the item’s position in that list, not of the item; this is why the edge layer exists, and it can also carry other relationship-specific data.
- A cursor is kept encoded; this is not obfuscation, it is a decision that declares its content is not part of the contract.
- While a list changes, offset pagination produces repeats on insertion and skips on deletion; cursor pagination navigates correctly in both cases.
- A skipped record costs more than a repeated one, because a skip is silent and produces no error.
- Pagination applies per field; in nested lists, every field respects its own limit, but the total response size can grow regardless of the limits.
Next Step
The connection model organized which records the response would carry, but it kept one assumption: every field asked for can be read, and every field returns a value. In the library, that is not true. A member’s fine information can be seen only by that member and by clerks; a loan record’s note can only be read at the branch where the record was opened. A field being unauthorized does not require rejecting the whole query — the rest of the response is still valid. The next lesson builds field-level authorization, shows what a single field’s crash touches in the response, and measures how partial data and an error come back together in the same response.
To keep your progress and take notes, Log in
My notes
Log in to take notes.