Pagination the Relay way

GraphQL Course · lesson 12 of 14 · 6 min read

Cursors over offsets, and why the connection shape looks odd.

Open this lesson in the learning hub

Key points

  • Offset pagination breaks on changing data: insert a row on page 1 and an item shifts onto page 2, so the reader sees it twice or not at all.
  • Cursor pagination encodes a position instead. "Give me 10 after this cursor" is stable regardless of inserts before it.
  • The Relay connection shape - edges, node, cursor, pageInfo - looks verbose, but the extra layer is what gives edges somewhere to carry per-relationship data.
  • The cursor must be opaque. Base64 of a sort key is fine; exposing a raw database id invites clients to construct their own, and then you can never change the scheme.
  • Cursors must be stable under the sort, so include a tie-breaker. Paging by created_at alone will skip or repeat rows sharing a timestamp.
  • totalCount is optional for a reason: it usually needs a second count query over the whole set. Expose it only where the client genuinely needs it.

Example

# The connection shape.
type Query {
  orders(first: Int, after: String, last: Int, before: String): OrderConnection!
}

type OrderConnection {
  edges: [OrderEdge!]!
  pageInfo: PageInfo!
  totalCount: Int          # optional - it costs a second query
}

type OrderEdge {
  node: Order!
  cursor: String!          # opaque
  addedAt: DateTime        # per-RELATIONSHIP data lives here, not on the node
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

---
# Querying it:
query Orders($after: String) {
  orders(first: 20, after: $after) {
    edges { cursor node { id total createdAt } }
    pageInfo { hasNextPage endCursor }
  }
}
#   next page: pass pageInfo.endCursor as $after

---
-- The SQL behind a cursor page. Note the TIE-BREAKER.
-- cursor decodes to (created_at, id)
SELECT * FROM orders
WHERE (created_at, id) < (:cursorCreatedAt, :cursorId)
ORDER BY created_at DESC, id DESC
LIMIT 21;                  -- fetch one extra to compute hasNextPage

-- Without the id tie-breaker, rows sharing a timestamp are skipped or
-- repeated at every page boundary - and it only shows up under load.

---
# Cursor encoding - opaque, and versioned so you can change it later:
#   raw     "v1:2026-08-03T10:00:00Z:12345"
#   cursor  base64 -> "djE6MjAyNi0wOC0wM1QxMDowMDowMFo6MTIzNDU="
#
# Clients must treat it as a token. Never document its contents, or it
# becomes an API you cannot change.

Cursors are stable where offsets are not - encode a sort key plus a tie-breaker, and keep the cursor opaque.

This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the GraphQL Course course, and every lesson in it is listed on the GraphQL Course contents page.