100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
GraphQL

Pagination Patterns: Cursor vs Offset

Compare offset-based and cursor-based pagination in GraphQL, and understand why the Relay connection spec is the industry-standard approach.

Advanced GraphQLIntermediate8 min readJul 10, 2026
Analogies

Two Ways to Paginate a List

Offset-based pagination asks for a page of results using a numeric offset and limit (or page and pageSize), telling the database to skip a number of rows and return the next chunk — simple to implement and simple for a client to jump to an arbitrary page number. Cursor-based pagination instead asks for results relative to an opaque cursor token that encodes a stable position in the result set, typically paired with first/after or last/before arguments, and it guarantees stable results even as the underlying data set is being inserted into or deleted from concurrently.

🏏

Cricket analogy: Offset pagination is like telling a scorer 'skip the first 40 balls and show me the next 10', while cursor pagination is like saying 'show me the 10 balls right after the one where Bumrah took that wicket' — a stable reference point rather than a shifting count.

The Data-Drift Problem with Offsets

Offset pagination has a well-known correctness flaw: if a row is inserted or deleted before the current offset window between two page requests, the client's next page shifts, causing items to be skipped entirely or shown twice — a real problem for any list that changes frequently, like a social feed or a live order queue. Offsets also get slower on large tables because most databases still have to scan and discard all the skipped rows even though they never return them, making OFFSET 1000000 LIMIT 20 measurably slower than OFFSET 20 LIMIT 20.

🏏

Cricket analogy: If a match commentary feed uses offset pagination and a correction is inserted for an earlier over, the next page 'skip 40, show 10' can duplicate or drop deliveries — much like a scorer accidentally re-announcing a boundary that was already read out.

The Relay Connection Spec

The Relay cursor connections specification standardizes cursor pagination into a predictable shape: a field returns a Connection type with an edges list, where each Edge wraps a node and its own opaque cursor, plus a pageInfo object exposing hasNextPage, hasPreviousPage, startCursor, and endCursor. Clients paginate forward with first and after, or backward with last and before, and because the spec doesn't mandate how a cursor is encoded, most implementations base64-encode something like a row's primary key or a composite sort value so the cursor is opaque and stable across requests.

🏏

Cricket analogy: The Relay Connection shape is like a broadcaster's standardized over-by-over ticker: each 'edge' is a delivery with its own timestamp cursor, and 'pageInfo' tells you whether there are more overs left to fetch, just like a match status flag.

graphql
type Query {
  posts(first: Int, after: String, last: Int, before: String): PostConnection!
}

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
}

type PostEdge {
  node: Post!
  cursor: String!
}

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

# Client query
query Feed($after: String) {
  posts(first: 10, after: $after) {
    edges {
      cursor
      node { id title }
    }
    pageInfo { hasNextPage endCursor }
  }
}

A cursor should be treated as an opaque token by clients — never parse or construct one manually. Most servers base64-encode a value like post:482 so the string is meaningless to the client but decodable server-side; changing the encoding later is a breaking change if clients ever inspect the cursor's contents.

Choosing the Right Pattern

Offset pagination is still reasonable when a client genuinely needs to jump to an arbitrary page number — an admin table with a page-number control, for instance — and the underlying data set is small or relatively static. Cursor pagination is the better default for infinite-scroll feeds, frequently mutated lists, and any API exposed to third-party clients, since it avoids the skip/duplicate bug, performs consistently on large tables when the cursor maps to an indexed column, and is what the broader GraphQL ecosystem — Relay, Apollo Client's fetchMore, GitHub's GraphQL API — has standardized around.

🏏

Cricket analogy: Choosing offset pagination for a static list like 'all-time top 50 Test batsmen' makes sense since the data barely changes, but a live 'ball-by-ball feed during an ongoing IPL match' needs cursor pagination to stay accurate as new deliveries stream in.

Cursor pagination is only as reliable as the sort order it's built on. If the underlying query lacks a stable, unique tie-breaker (e.g., sorting only by a non-unique createdAt timestamp), rows with identical values can be skipped or duplicated across pages — always sort by a unique column or a composite key that includes one.

  • Offset pagination uses numeric skip/limit and allows jumping to arbitrary pages, but drifts when data changes between requests.
  • Cursor pagination anchors to an opaque token representing a stable position, avoiding the skip/duplicate problem.
  • Offset queries get slower on large tables because the database must scan and discard skipped rows.
  • The Relay connection spec standardizes cursor pagination with edges, node, cursor, and pageInfo.
  • Cursors should be treated as opaque by clients, even though they're often just base64-encoded IDs internally.
  • Cursor pagination requires a unique, stable sort key to avoid skipped or duplicated rows.
  • Offset pagination remains reasonable for small, static datasets needing arbitrary page-number access.

Practice what you learned

Was this page helpful?

Topics covered

#GraphQL#GraphQLStudyNotes#WebDevelopment#PaginationPatternsCursorVsOffset#Pagination#Patterns#Cursor#Offset#APIs#StudyNotes#SkillVeris