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.
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, andpageInfo. - 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
1. What is the main correctness problem with offset-based pagination?
2. In the Relay connection spec, what does each item in the `edges` list contain?
3. Why does cursor pagination require a unique, stable sort key?
4. Which arguments does the Relay spec use to paginate backward through a connection?
5. Why is `OFFSET 1000000 LIMIT 20` typically slower than `OFFSET 20 LIMIT 20` in most databases?
Was this page helpful?
You May Also Like
Subscriptions Explained
Learn how GraphQL subscriptions deliver real-time updates over a persistent connection, and how to run them reliably in production.
Directives in GraphQL
Understand built-in and custom GraphQL directives, how they alter query execution and schema behavior, and where to apply them safely.
Schema Stitching and Federation
Learn the two main approaches to composing multiple GraphQL services into a single unified graph, and when to choose each.
Related Reading
Related Study Notes in Web Development
Browse all study notesWebSockets Study Notes
Web Development · 30 topics
Web DevelopmentWebAssembly Study Notes
WebAssembly · 30 topics
Web DevelopmentgRPC Study Notes
Protocol Buffers · 30 topics
Web DevelopmentSpring Boot Study Notes
Java · 30 topics
Web DevelopmentFlask Study Notes
Python · 30 topics
Web DevelopmentDjango Study Notes
Python · 30 topics