If you have worked with pagination before, you are most likely familiar with the offset pagination model, common for REST APIs. But for GraphQL servers, is offset pagination always the right choice or are there any alternatives? In this blog post, we’ll go through what is cursor-based pagination, its pros and cons, how to utilize it, and what to expect from it in a GraphQL environment. Enjoy.
What is Cursor-based Pagination?
Cursor-based pagination is a technique for slicing huge data sets into more manageable pieces using cursors – a string that identifies a specific point in the result set. It works by assigning a cursor for every node in the result set, when it can be used to fetch the next or previous items. Since it can also be built on top of other pagination models, such as offset or ID-based pagination, it is recommended for use with GraphQL servers.
You can think of a cursor as a pointer: they store the reference to an object. Typically, cursors are encoded in base 64, which makes them opaque to the client [1]. By "opaque," we mean that the cursor is just an arbitrary string that has no meaningful interpretation for the client.
Cursor-based pagination is recommended in scenarios where data change frequently or in real-time [2]. The item search works with pointers instead of offsets – which also makes the queries more efficient , so even when new data is added, the result of the same query in different points in time will be the same. Cursor-based pagination also works well with systems with infinite scrolling, since the page will always point to the next set of items in the list [2].
On the other hand, it has its own set of limitations. Cursor-based pagination does not support jumping to an arbitrary page, whereas in other pagination models such as offset-based pagination, this is possible [2]. Additionally, when compared with other pagination models, cursor-based pagination is considerably harder to implement [3].
How to query using cursors
Before diving into the details of querying, it’s important to understand that cursor-based pagination allows us to retrieve data in two directions: forward and backward. However, it’s crucial to note that this is different from changing the order of the results. As you will see, the order of the result set remains the same using either direction.
The pagination direction is defined by the arguments that we are passing to our query. first
and after
result in forward pagination, whereas last
and before
result in backward pagination. Although possible, it is not recommended to use both first
/after
and last
/before
arguments together to avoid confusing results [4].
The arguments first
and last
are numbers that determine the number of records per page in the result. after
and before
are cursors used as references to fetch the next or previous items from a certain point.
If you are used to offset-based pagination, you’ll notice that the result will look a little more complex. We will see what those other fields mean later. For the following examples, we’ll use the first 151 Pokémon as the data set.
Forward pagination
Let’s get started with a simple query paginating forward:
query {
pokemon(first: 2) {
edges {
node {
number
species
}
cursor
}
}
}
This would produce the following output:
{
"data": {
"pokemon": {
"edges": [
{
"node": {
"number": 1,
"species": "Bulbasaur"
},
// usually base64. plain text for demonstration purposes
"cursor": "cursor_1"
},
{
"node": {
"number": 2,
"species": "Ivysaur"
},
"cursor": "cursor_2"
}
]
}
}
}
To get the next page after this result, we will need to use the cursor of the last item and its value in another query:
query {
pokemon(first: 2, after: "cursor_2") {
edges {
node {
number
species
}
cursor
}
}
}
This would produce the following output:
{
"data": {
"pokemon": {
"edges": [
{
"node": {
"number": 3,
"species": "Venusaur"
},
"cursor": "cursor_3"
},
{
"node": {
"number": 4,
"species": "Charmander"
},
"cursor": "cursor_4"
}
]
}
}
}
Notice the result exclusiveness: the item the cursor represents is not included in the result. In SQL, it would be virtually the same as the following query:
SELECT *
FROM my_table
WHERE id > cursor;
Backwards pagination
Backwards pagination is done by using the arguments last
and before
, and it’s used to traverse the data set back to front:
query {
pokemon(last: 2) {
edges {
node {
number
species
}
cursor
}
}
}
Would return:
{
"data": {
"pokemon": {
"edges": [
{
"node": {
"number": 150,
"species": "Mewtwo"
},
"cursor": "cursor_150"
},
{
"node": {
"number": 151,
"species": "Mew"
},
"cursor": "cursor_151"
}
]
}
}
}
Keep in mind that to get the values in the next page, we’ll need to use the cursor of the first item as an anchor instead of the last one:
query {
pokemon(last: 2, before: "cursor_150") {
edges {
node {
number
species
}
cursor
}
}
}
This would return:
{
"data": {
"pokemon": {
"edges": [
{
"node": {
"number": 148,
"species": "Dragonair"
},
"cursor": "cursor_148"
},
{
"node": {
"number": 149,
"species": "Dragonite"
},
"cursor": "cursor_149"
}
]
}
}
}
Order vs Direction
Cursor-based pagination does not support arguments for sorting the result set. Usually, the order of the result set is ascending and will remain unaltered, independently of the query being made with first
/after
or last
/before
[4]. Instead, what we can do is paginate the data set in different directions. Let’s make the difference clear:
- Order refers to the order in which the result set is sorted by, and will either be ascending or descending;
- Direction refers to the direction in which we are traversing the data set.
Notice in our previous example that no matter what direction we used to fetch the data, the result kept the same order: ascending by Pokédex number. In other words, the order of the result set won’t change, what changes is the direction in which we traverse the data set.
To finish this section off, this is what a query that retrieves all fields will look like:
query {
example {
edges {
node {
id
// fields related to the resource
}
cursor
}
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
}
}
In the next section, we’ll be making an overview of those fields and what they represent in a GraphQL query.
Specification for GraphQL servers
To ensure that cursor-based pagination has a standard among various applications, Relay has an online specification that developers can apply to GraphQL servers. This section will go through the main points of the specification so you know what to expect.
GraphQL Connections Specification
The GraphQL Connection Specification is an option for GraphQL servers to implement good practices of pagination through a pattern called "Connections", exposing them in a standardized manner. For queries, it offers a way of navigating and paginating the result set. For the response, a way of presenting cursors and tells the client if more pages are available [4].
A server in conformity with the spec must reserve two main types: Any object that ends with "Connection", and PageInfo
[4].
Connection
A Connection is the representation of the paginated result, and it will look familiar to a REST API response. In the specification, any object that ends with "Connection" is treated as one. It must have two fields: pageInfo
and edges
[4].
Beyond the reserved types, the Connection model might also include additional fields that might make sense for its context. For example, a field totalCount
that returns how many items exist [4]. In a server, the connection type will look like this:
type ExampleConnection {
edges: [ExampleEdge]
pageInfo: PageInfo!
# you can add more fields as you see fit
totalCount: Int!
}
PageInfo
The type PageInfo
contains the cursors of the first and last edges and inform the client if previous or next pages exist [4]. It is quite simple:
type PageInfo {
startCursor: String
endCursor: String
hasNextPage: Boolean!
hasPreviousPage: Boolean!
}
Edge
Edge types are objects that must be returned in a list on the edges
field of a Connection. It must contain the fields cursor
and node
:
type ExampleEdge {
node: ExampleNode!
cursor: String!
}
The cursor
field in the Edge type will be a unique identifier to its respective object. It will be utilized in the pagination args – either before
or after
– as an anchor to get the next fields of the data set.
Node
The node
field on the Edge type represents a single item of the sliced result set. It must be a Scalar, Enum, Object, Interface, Union, or a Non-Null wrapper of one of those types, but notably, never a list [4].
Spec-compliant clients might opt for creating an interface Node
and making the Edge return a type that implements that interface:
interface Node {
id: String!
}
type ExampleNode implements Node {
id: String
// ...
}
This practice, however, is not obligatory.
Conclusion
While cursor-based pagination offers significant benefits for managing large data sets in dynamic environments, it also comes with certain drawbacks. Ultimately, the decision between cursor-based pagination and alternative models like offset pagination should be guided by the specific requirements of your application, the nature of your data, and user experience considerations. By carefully evaluating these factors, you can select the pagination strategy that best fits your needs. Thank you for reading along and happy coding!
References
- [1] https://graphql-ruby.org/pagination/cursors.html
- [2] https://medium.com/@maryam-bit/offset-vs-cursor-based-pagination-choosing-the-best-approach-2e93702a118b
- [3] https://www.merge.dev/blog/cursor-pagination
- [4] https://relay.dev/graphql/connections.htm
We want to work with you. Check out our "What We Do" section!