Offset vs Cursor Pagination in GraphQL: Which to Choose and How to Implement
Picking pagination for a GraphQL API is a trade-off between simplicity and stability. This guide compares offset and cursor pagination, shows trade-offs, and gives a concrete Relay Connection example you can validate.
21 Jan 2026, 21:10 UTC

Picking pagination for a GraphQL API is not about style. It is about how your data changes while clients page through it, and how far they need to page. The common decision is between offset-based pagination with limit and offset, and cursor-based pagination following the Relay Cursor Connections Specification with after and before.
The useful takeaway: use offset pagination when you need simple page numbers and shallow reads on mostly static data. Use cursor pagination when you need stable results under inserts/deletes and predictable performance at depth.
Decision context and constraints
Constraints that drive the choice:
- Data volatility. If rows are inserted or deleted between requests, offset pagination can skip or duplicate items.
- Depth of paging. Clients requesting page 1000+ make offset expensive.
- Client needs. Page numbers and random jumps require offset. Infinite scroll and chronological feeds work well with cursors.
- Implementation cost. Offset maps directly to SQL
LIMIT/OFFSET. Cursor requires an opaque pointer and a stable sort key.
A cursor is an opaque string that encodes a position in a sorted list, not a raw ID. It is typically base64 of the sort key values.
Options compared
| Aspect | Offset pagination | Cursor pagination |
|---|---|---|
| Arguments | first/last + offset | first/last + after/before |
| Database pattern | LIMIT n OFFSET m | WHERE sort_key > cursor_value ORDER BY sort_key LIMIT n |
| Performance at depth | Linear degradation; DB scans preceding rows | Constant-time with indexed sort key |
| Stability under writes | Duplicates/skips possible | Stable traversal |
| Random page jump | Supported | Not supported |
| Complexity | Low | Higher |
Trade-offs in practice
Offset pagination is simple to implement and easy for clients to reason about. It maps directly to SQL and supports page = 5. The cost is that the database must walk offset rows before returning results, and inserts at the start of the ordering shift subsequent rows.
Cursor pagination provides stable pages because each request starts from a known position. It uses an indexed filter on the sort key, so deep pages do not get slower. The Relay Connection model standardizes the shape with edges, node, and pageInfo for consistent client caching.
Cautions to keep in mind. Offset pagination can return duplicate or skipped items if the underlying dataset changes between requests. Cursor pagination does not support jumping to page 42. Exposing raw database IDs as cursors creates coupling and potential information leakage; cursors should be opaque.
Concrete schema shapes
Offset style
type Query {
posts(limit: Int = 20, offset: Int = 0): [Post!]!
}
type Post {
id: ID!
createdAt: String!
title: String!
}
A resolver for offset typically builds LIMIT $limit OFFSET $offset with an explicit ORDER BY createdAt DESC, id DESC. Required permissions are read access to the posts table. Risk: large offsets cause slow queries and unstable results.
Relay Connection style
type Query {
posts(first: Int, after: String): PostConnection!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge {
cursor: String!
node: Post!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
The cursor can be base64 of createdAt:id. A resolver filters with WHERE (createdAt, id) > decoded_cursor and orders by the same keys. Run validation in a GraphQL playground against your schema. Expected checks: pageInfo.hasNextPage is correct, edges are in stable order, and decoding a cursor returns the expected position.
Validation approach
Validate the decision with three practical checks.
- Measure latency as page depth increases. Implement both
offsetandafterarguments on the same field and compare query latency for shallow and deep pages. - Test stability under writes. While paginating with offset, insert a new record at the beginning of the sorted list and observe duplicate or skipped items. Repeat with cursor pagination and confirm the traversal remains consistent.
- Validate shape against the Relay spec. Ensure
edges,node,cursor, andpageInfoare present and that cursors are opaque.
Limitations. Cursor pagination requires a total order. Composite keys are common for uniqueness. It does not help if clients insist on arbitrary page numbers. Offset pagination remains acceptable for admin tables with low volume and infrequent writes.
A practical way to check the result is to query the first page, capture endCursor, then query again with after: endCursor and verify no overlap and no gaps in the ordered field.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.