The Offset Bug You Ship Without Noticing
The Offset Bug You Ship Without Noticing
Offset pagination is skip/limit, and it corrupts a live feed the moment someone posts while a reader is scrolling:
Table: A B C D E F G H
Page 1: skip 0 limit 3 → A B C reader finishes A, B
New post X inserted at the top
Page 2: skip 3 limit 3 → C D E C repeats; the row the reader
expected (D) arrives one slot early
Two failure modes fall out of counting positions instead of referencing rows: duplicates when rows are inserted ahead of the offset, and skips when rows are deleted ahead of it. Neither appears in a test suite that runs against a frozen table, which is exactly why they survive to production.
Offset also gets slower as it grows — LIMIT 10 OFFSET 100000 makes the database produce 100,010 rows and discard 100,000 of them.
Cursor pagination replaces counting with a pointer. The server hands back an opaque token for the last row seen, and the next request resumes from that position:
-- offset: counts rows it must throw away
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 100000;
-- keyset: seeks straight to the resume point
SELECT * FROM posts WHERE (created_at, id) < ('2026-01-01', 821)
ORDER BY created_at DESC, id DESC LIMIT 10;
Relay is the GraphQL convention that wraps this idea in a shape every client can code against.
The cost asymmetry matters as much as the correctness one: an offset query does more work the deeper a reader scrolls, while a keyset query seeks to the boundary row and reads only the page it returns. Page 5,000 of an offset feed is a full scan in disguise — the database produces 50,000 rows to hand back ten. That is why the first decision for a GraphQL list field is not "how many per page" but "what identifies a row's position in the order".
The Connection Shape: Edges, Nodes, PageInfo
The Connection Shape: Edges, Nodes, PageInfo
Relay wraps a list in three layers, each with one job:
type PostEdge {
cursor: String! # opaque pointer to this row within the sort order
node: Post! # the record itself
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type PostConnection {
edges: [PostEdge!]!
nodes: [Post!]! # convenience: edges.map(e => e.node)
pageInfo: PageInfo!
}
type Query {
posts(first: Int, after: String, last: Int, before: String): PostConnection!
}
The document a client writes:
query FirstPage {
posts(first: 2, after: "Y3Vyc29yOjE=") {
pageInfo { hasNextPage endCursor }
edges {
cursor
node { id title }
}
}
}
{
"data": { "posts": {
"pageInfo": { "hasNextPage": true, "endCursor": "Y3Vyc29yOjM=" },
"edges": [
{ "cursor": "Y3Vyc29yOjI=", "node": { "id": "2", "title": "…" } },
{ "cursor": "Y3Vyc29yOjM=", "node": { "id": "3", "title": "…" } }
]
}}
}
| Piece | Answers the client question |
|---|---|
first + after |
"the next N rows after this pointer" |
last + before |
"the previous N rows before this pointer" |
edges[].cursor |
"where does this row sit in the order" |
nodes |
"give me the records without cursor ceremony" |
pageInfo.hasNextPage |
"should the UI render a next control" |
Connections nest like any other field, which is what makes them the right answer for lists inside lists — posts { comments(first: 10) { … } } — and each nested connection is one more resolver with its own parent to read an id from.
Two details worth internalising: startCursor exists so a list can render a "previous" control without the client keeping its own history of every page it has seen, and edges is what makes per-row cursors possible at all — nodes alone cannot tell you where the page ended, which is why a connection exposes both.
Opaque Cursors, Backwards Pages, and the totalCount Bill
Opaque Cursors, Backwards Pages, and the totalCount Bill
A cursor is a database resume token, not an index. Encode the sort key rather than a position:
const encodeCursor = (row) =>
Buffer.from(`${row.created_at}|${row.id}`).toString('base64');
// decode → WHERE (created_at, id) < ('2026-01-01 00:00:00', 821)
Three rules make cursors stable:
- Sort deterministically. A tie-breaker column (
id) is mandatory. Rows sharingcreated_atotherwise shuffle between pages, and every page boundary becomes a coin flip. - Filter on the key, not the position. Keyset comparison is unaffected by inserts above the cursor, so page 2 after a new post still starts where page 1 ended.
- Fetch
first + 1rows. Ask for one extra row sohasNextPageis a fact rather than a guess:
const rows = await db.post.findMany({ take: first + 1, cursor, skip: 1, orderBy });
const hasNextPage = rows.length > first;
const edges = rows.slice(0, first).map((node) => ({ cursor: encodeCursor(node), node }));
(skip: 1 starts after the row the cursor points at; without it the cursor row reappears on every page.)
totalCount is optional and expensive. It forces a COUNT(*) over the whole filtered set on every page — on a large table that can cost more than the page itself, and it cannot be cached independently of writes. Return it where the UI genuinely displays "1,204 results" (search headers, admin tables); omit it from infinite-scroll feeds where nothing renders it.
The client payoff: endCursor from page 1 is the entire input to page 2, so the UI stores one opaque string and no offset that can drift. Nested connections bring back the per-level resolver cost — if a feed of 20 posts each opens a 10-comment connection, that is 21 queries unless the comment lookups are batched. See the N+1 pattern.
Interview framing: state why offsets drift under writes, describe the keyset comparison in SQL, then explain first + 1 as the honest way to compute hasNextPage.
Practice Problems
Write the query that fetches the first two posts after the cursor "Y3Vyc29yOjE=", including each edge cursor, the node fields, and pageInfo. Then show the exact document for page two using only the response you received.
Example:
Input: type Query { posts(first: Int, after: String, last: Int, before: String): PostConnection! } Given pageInfo.endCursor from page 1 = "Y3Vyc29yOjM="
Output: query PageOne { posts(first: 2, after: "Y3Vyc29yOjE=") { pageInfo { hasNextPage hasPreviousPage endCursor } edges { cursor node { id title } } } } query PageTwo { posts(first: 2, after: "Y3Vyc29yOjM=") { pageInfo { hasNextPage endCursor } edges { cursor node { id title } } } }
Page two reuses endCursor from page one as its after argument — the client stores one opaque string and never an offset, so a row inserted at the top of the table cannot shift the next page.
Optimal Solution — O(n) time, O(n) space
Ask for first + a cursor, read endCursor/hasNextPage, and pass endCursor straight back as after. n = rows returned per page.
query Page($after: String) {
posts(first: 2, after: $after) {
pageInfo { hasNextPage endCursor }
edges {
cursor
node { id title }
}
}
}
# page 1: { "after": null }
# page 2: { "after": "<endCursor from page 1>" } Edge Cases:
- hasNextPage false — stop requesting pages instead of fetching an empty one to find out
- A row deleted between pages — cursor pagination skips nothing it never pointed at
- Ties on the sort column — without an id tie-breaker the cursor can repeat or drop rows
Finish the connection resolver so it decodes the cursor into a keyset filter, fetches one extra row to compute hasNextPage, and returns stable cursors. Explain why skip: 1 appears alongside cursor.
Example:
Input: // posts(first: 2, after: "Y3Vyc29yOjE=") → // cursor decodes to created_at = 2026-01-01, id = 821 const posts = async (_, { first, after }, { db }) => { /* ... */ };
Output: const posts = async (_, { first, after }, { db }) => { const decoded = after ? decodeCursor(after) : null; const rows = await db.post.findMany({ where: decoded ? { OR: [ { createdAt: { lt: decoded.createdAt } }, { createdAt: decoded.createdAt, id: { lt: decoded.id } }, ] } : undefined, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], take: first + 1, // one extra row answers hasNextPage }); const hasNextPage = rows.length > first; const page = rows.slice(0, first); return { edges: page.map((node) => ({ cursor: encodeCursor(node), node })), pageInfo: { hasNextPage, hasPreviousPage: Boolean(after), startCursor: page.length ? encodeCursor(page[0]) : null, endCursor: page.length ? encodeCursor(page[page.length - 1]) : null, }, }; };
The composite keyset comparison resumes exactly after the cursor row even when timestamps tie. Fetching first + 1 makes hasNextPage a fact rather than a guess, and slicing back to first keeps the page size correct.
Optimal Solution — O(log n + first) time, O(first) space
Keyset filter on (createdAt, id), take first + 1, slice to first, encode each node sort key as the cursor. skip: 1 is needed when using a database cursor pointed at the boundary row rather than a where clause, because the cursor row itself would otherwise be returned again.
const rows = await db.post.findMany({
where: keysetFilter(decoded), // (created_at, id) < (?, ?)
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: first + 1,
});
const hasNextPage = rows.length > first;
const edges = rows.slice(0, first).map((node) => ({
cursor: Buffer.from(node.createdAt + '|' + node.id).toString('base64'),
node,
})); Edge Cases:
- after absent — first page: no filter, hasPreviousPage false
- Fewer rows than first returned — hasNextPage false, page still returns its rows
- Two rows with identical createdAt — the id tie-breaker keeps the cursor total order
Quiz
1. Page 1 returns rows 1-10, a new row is inserted at the top, and the client requests page 2 with offset 10. What happens?
2. How does hasNextPage usually get computed in a cursor resolver?
3. Why are cursors returned as opaque strings rather than numeric offsets?
4. When is totalCount worth returning on a connection?
Flashcards
Question
Connection layers
Click to reveal answer
Answer
Connection { edges { cursor node } nodes pageInfo } — edges carry cursors, nodes are the records, pageInfo drives controls.
Question
Offset vs cursor under writes
Click to reveal answer
Answer
Offset counts positions so inserts duplicate and deletes skip; cursors resume from the last row seen, so pages stay stable.
Question
hasNextPage recipe
Click to reveal answer
Answer
Fetch first + 1 rows; the extra row exists ⇒ hasNextPage true. Slice back to first for the edges.
Question
Why totalCount is optional
Click to reveal answer
Answer
It forces COUNT(*) over the full filtered set per page. Return it only when the UI shows an exact total.
Question
Deterministic sort requirement
Click to reveal answer
Answer
Always add a unique tie-breaker (id) to the sort, or rows with equal timestamps shuffle across pages.
Revision Notes
Key Takeaways
- 1. Offset pagination drifts under concurrent writes; keyset cursors resume from data, not from a counted position
- 2. The connection envelope separates records (nodes) from navigation metadata (cursors, pageInfo)
- 3. Compute hasNextPage by fetching one extra row, and sort with a unique tie-breaker or pages shuffle
- 4. totalCount is a per-page COUNT(*) — buy it only when a screen displays the number
Interview Tips
- • Give the concrete duplicate-row scenario: insert between two page requests, then show why offset skips a row and keyset does not
- • Write the keyset SQL with a composite comparison and explain the role of the id tie-breaker
- • Explain first + 1 as the cheap hasNextPage computation, and say when you would still return totalCount
Cheat Sheet
Relay Connection Cheat Sheet
query Next($after: String) {
posts(first: 10, after: $after) {
pageInfo { hasNextPage hasPreviousPage endCursor }
edges { cursor node { id title } }
}
}
const rows = await db.findMany({ take: first + 1, cursor, skip: 1, orderBy });
const hasNextPage = rows.length > first;
first+after → forward;last+before → backward- Cursor = base64 of the sort key (createdAt + id), not an offset
- Keyset filter:
WHERE (created_at, id) < (?, ?)— immune to inserts above the cursor - Sort must include a unique tie-breaker or pages shuffle
nodes=edges.map(e => e.node);startCursor/endCursorfeed list controlstotalCount= full COUNT(*) per page: search headers yes, infinite feeds no