Skip to content
advanced Phase 5 · Nested Selections and Object Relationships

Relay-style Connections and Pagination

Edges, nodes, cursors, and page info for paginating nested GraphQL lists.

55m
2 problems
Topic Progress 0%

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 sharing created_at otherwise 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 + 1 rows. Ask for one extra row so hasNextPage is 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

0 / 2 solved
Write a Relay connection query and derive page two
Relay connections

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
Complete the cursor resolver with honest hasNextPage
Keyset pagination implementation

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?

Question 1 options

2. How does hasNextPage usually get computed in a cursor resolver?

Question 2 options

3. Why are cursors returned as opaque strings rather than numeric offsets?

Question 3 options

4. When is totalCount worth returning on a connection?

Question 4 options

Flashcards

Question

Connection layers

Answer

Connection { edges { cursor node } nodes pageInfo } — edges carry cursors, nodes are the records, pageInfo drives controls.

Question

Offset vs cursor under writes

Answer

Offset counts positions so inserts duplicate and deletes skip; cursors resume from the last row seen, so pages stay stable.

Question

hasNextPage recipe

Answer

Fetch first + 1 rows; the extra row exists ⇒ hasNextPage true. Slice back to first for the edges.

Question

Why totalCount is optional

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

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/endCursor feed list controls
  • totalCount = full COUNT(*) per page: search headers yes, infinite feeds no

Continue Learning