Cursor vs. Offset Done Right
Pagination is not a minor detail in GraphQL. Anyone who simply writes offset and limit into the schema has a working solution, but not a robust one. Cursor-based pagination following the Relay specification takes more effort, but it solves exactly the problems that offset pagination creates as data volumes grow and concurrent writes happen.
Table of Contents
- 1. Why pagination in GraphQL is not a trivial parameter
- 2. Offset pagination: simple, understandable, limited
- 3. Cursor pagination and the Relay specification
- 4. Schema design: building connection types correctly
- 5. Resolver implementation: generating and evaluating cursors
- 6. Common mistakes and pitfalls
- 7. Pagination in Magento GraphQL
- 8. Cursor vs. offset compared directly
- 9. Summary
- 10. At a glance
- 11. FAQ
1. Why pagination in GraphQL is not a trivial parameter
Pagination is one of those topics that gets underestimated quickly in GraphQL APIs. A simple limit and offset argument is enough for the first prototype, but as soon as data volumes grow, multiple users write concurrently, or frontend teams need stable page navigation, the limits of this approach become clear. The GraphQL community responded to this and defined a mature pattern for cursor-based pagination in the Relay specification, which is supported by most large GraphQL implementations today.
The difference is not just technical, it is conceptual: offset pagination thinks in absolute positions, cursor pagination in stable pointers to records. That sounds abstract, but it becomes very concrete once new records are inserted between two requests and every position shifts. Choosing the wrong model for your use case bakes a stability problem into the API that is hard to undo.
2. Offset pagination: simple, understandable, limited
Offset pagination works with two parameters: limit (how many entries are returned) and offset (how many entries are skipped). The model is intuitive and maps directly to SQL: SELECT * FROM products LIMIT 20 OFFSET 40. For simple lists with stable data, for example settings pages in an admin panel, offset pagination is entirely sufficient. Implementation effort is low and the mental complexity stays manageable.
Problems start once the underlying data is not stable. If a product is inserted between two requests, the whole list shifts by one position, and the client either receives a duplicate record on the next request or skips one entirely. Large offsets also cause performance problems at the database level: the database has to materialize every record up to the offset, even if it is never returned. Around 10,000 records of offset, this becomes measurable. Offset pagination is therefore fine for stable, manageable datasets, but unsuitable for feeds, product listings, and other frequently changing collections.
# Offset-based pagination, simple but limited for live data
query ProductsWithOffset {
products(
search: "jacket"
pageSize: 20
currentPage: 3
) {
total_count
page_info {
current_page
page_size
total_pages
}
items {
sku
name
price_range {
minimum_price {
final_price { value currency }
}
}
}
}
}
3. Cursor pagination and the Relay specification
The Relay specification describes a standardized way to implement cursor pagination in GraphQL. Instead of limit and offset, it uses the arguments first, after, last, and before. The cursor is an opaque string, typically a base64-encoded representation of a unique value such as an ID or a timestamp, that the server attaches to every entry. The client sends this cursor back to request the next page. The result always includes a pageInfo object with hasNextPage, hasPreviousPage, startCursor, and endCursor.
The central advantage: the server no longer needs to know an absolute position, only the last record it saw. That makes the query stable against inserts and allows an efficient database-level implementation with WHERE id > cursor_id ORDER BY id LIMIT n. Cursors are also more flexible: they can encode composite sort keys and therefore paginate complex sort orders stably, something that is not possible with plain offsets.
# Relay-style cursor pagination, stable and efficient
query ProductsWithCursor($after: String) {
productConnection(first: 20, after: $after) {
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
cursor
node {
id
sku
name
price_range {
minimum_price {
final_price { value currency }
}
}
}
}
totalCount
}
}
4. Schema design: building connection types correctly
The Relay pattern introduces its own type structure that looks cumbersome at first glance but guarantees reusability and consistency across every paginated list. The central type is the Connection: it contains edges (a list of edge objects) and pageInfo. Each edge contains cursor and node (the actual record). This nesting lets the cursor travel with each individual element rather than only with the overall list. For different entities you define different connection and edge types, all of which share the same PageInfo interface.
In the schema definition it matters that the PageInfo object is defined as its own type rather than embedded inline. That makes it reusable across every connection. The arguments first: Int, after: String, last: Int, before: String are part of the Relay standard and should be named consistently. Custom arguments such as filters or sort can sit alongside the pagination arguments without breaking the structure.
# Schema definition following Relay Connection specification
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type ProductEdge {
cursor: String!
node: ProductNode!
}
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ProductNode {
id: ID!
sku: String!
name: String!
price: Float
}
type Query {
# Relay-compatible connection field alongside optional filters
productConnection(
first: Int
after: String
last: Int
before: String
search: String
sort: ProductSortInput
): ProductConnection!
}
5. Resolver implementation: generating and evaluating cursors
The resolver implementation is the most involved part of cursor pagination. The cursor must be derived deterministically from the record, usually from the primary ID or a combination of the sort field and the ID. Base64 is not used as a security measure here, but as a signal to client developers that the cursor should be treated as an opaque value and must not be constructed manually. The resolver decodes the incoming after cursor, transforms it into a database condition, and returns the pageInfo object together with the results.
A common mistake is computing hasNextPage incorrectly. The correct method is to load one page more than requested (querying first + 1 records), and if more records came back than were requested, set hasNextPage: true and drop the extra last element. That is more efficient than a separate COUNT query for every page request. When paginating backward with last and before, the same logic applies in mirror image, and the order of the results must be reversed accordingly.
6. Common mistakes and pitfalls
The most common mistake in cursor pagination is using sequential integer IDs as the cursor without encoding them. Once client developers notice that cursor: "42" is simply an ID, they start constructing cursors manually, with the result that the API abstraction is undermined and frontend code breaks on schema changes. Base64 encoding with a prefix such as base64("product:42") makes it clear that the cursor is meant to be treated as opaque.
Another frequent problem is missing or incorrect pageInfo. If hasNextPage is always set to false, or forgotten, cursor pagination becomes effectively useless. The frontend then has no way to tell whether more data exists. startCursor and endCursor also need to be correct: they are not automatically the same as the cursor of the first and last entry in the edges list, even though that is how it is often implemented. It is only correct if the sort order is identical on every request.
7. Pagination in Magento GraphQL
Magento 2 implements offset pagination through the parameters pageSize and currentPage. That is consistent with the internal SearchCriteria layer and sufficient for most product listing use cases. The page_info type returns current_page, page_size, and total_pages. Anyone writing custom queries and resolvers for Magento and needing a different pagination strategy has to define the connection types in the schema themselves and build the resolver accordingly.
Extra caution is needed with large product catalogs: Magento's search through OpenSearch returns correct results, but very large pageSize values (for example 500) can put load on the search server and significantly increase response time. The total_count field can also be expensive on complex filter queries, since it triggers a separate count query. In performance-critical scenarios it makes sense to cache total_count separately, or to drop it entirely for infinite-scroll use cases and return only hasNextPage instead.
8. Cursor vs. offset compared directly
Both models have their place, and the decision depends on the concrete use case. Offset is easier to implement and debug, cursor pagination is more stable and scalable. The following table summarizes the key differences.
| Criterion | Offset pagination | Cursor pagination (Relay) | Recommendation |
|---|---|---|---|
| Stability under inserts | Data can appear twice | Stable, cursor points to a record | Cursor for feeds and frequently changing lists |
| Performance at large offsets | Slower (DB has to skip records) | Consistently fast | Cursor from around 10,000+ records |
| Implementation effort | Low | Higher (edge, PageInfo, cursor logic) | Offset for admin UIs and stable lists |
| Jumping to page X | Directly possible | Not possible (sequential) | Offset when page navigation is required |
| Infinite scroll / load more | Error-prone under concurrent writes | Ideal fit | Cursor for all feed-style UIs |
9. Summary
Pagination in GraphQL is an architectural decision, not an implementation detail. Offset pagination is quick to implement and easy to understand, but shows clear limits as data volumes grow and writes become frequent. Cursor pagination following the Relay specification takes more effort but delivers stable, scalable results for every use case where the data is not static. The choice should be made deliberately, depending on the concrete use case, and once made, it is hard to undo, because clients come to rely on the cursor structure.
In Magento GraphQL, offset pagination is the default and sufficient for most catalog use cases. Anyone writing custom queries with different requirements should plan for the Relay types from the start and keep the cursor logic cleanly encapsulated in the resolver, not in the controller or the business logic. That keeps the resolver testable and the contract with the frontend explicit.
Pagination in GraphQL, the essentials at a glance
Offset pagination
Simple, maps directly to SQL, ideal for admin UIs and stable lists, but unstable under concurrent writes and slow at large offsets.
Cursor pagination (Relay)
Stable, performant on large datasets, and ideal for feeds and infinite scroll. Higher implementation effort, but clearly better long-term quality.
Magento default
pageSize + currentPage via SearchCriteria, sufficient for catalog pages, but performance-critical with very large pageSize values and expensive total_count queries.
Rule of thumb
Cursor for all feed-style, frequently changing lists. Offset for stable data and page navigation. Make the decision early, it is hard to undo.