GraphQL Cursor Connections: Cursor Specification

Riley Conrardy
5 min readJul 22, 2023

--

Introduction

The Relay GraphQL Cursor Connection Specification illustrates how to implement cursor-based pagination in GraphQL. However, it remains silent on the specifics of cursor creation. This article will introduce an opinionated specification for creating cursors in your cursor connections. Understanding how to create effective cursors is crucial for enhancing data retrieval efficiency in GraphQL.

The Concept of Cursors

At its core, a cursor is an opaque string that acts as a bookmark within a data set. It serves as a reference point, enabling developers to pick up where they left off during pagination and efficiently access subsequent records. The flexibility of cursor creation allows developers to define the specific information the cursor represents. Typically, cursors should contain at least a subset of data related to the node they were created for. Depending on the requirements of the connection query, additional metadata may also be included within the cursor.

Cursor Data Object

While not strictly enforced, we suggest including only data that is necessary for subsequent requests in the cursor data object. By keeping the cursor data concise, we can mitigate the impact on the response payload size, potentially reducing response times and optimizing network usage.

  • “args” Field: The Cursor Data Object may contain an optional “args” field, which holds a subset of the connection arguments used in the original query.
  • “ref” Field: The Cursor Data Object must have a “ref” field, which contains a subset of data from the node that the cursor was created. This data should provide enough context for subsequent data retrieval.

Cursor Object

Data security is of utmost importance, and as such, this spec requires that all cursor data be encrypted. While not mandating specific encryption algorithms, we recommend utilizing the Advanced Encryption Standard (AES) for enhanced security.

  • Security-Related Fields: A Cursor Object may contain optional fields related to security, such as an initialization vector (IV), which aids in the encryption process.
  • “data” Field: The Cursor Object must have a “data” field, which contains an encrypted Cursor Data Object. This encryption ensures that sensitive information remains protected and hidden from the client.

Cursor

To maintain the opaque and server-side nature of cursors, they should not be human-readable. Therefore, cursors must be encoded before they are sent to the client. While this specification does not impose any specific encoding algorithms, we recommend using Base64 or HEX encoding for simplicity and compatibility.

Example Cursor Generation

To provide examples in this article we will generate cursors for the “products” query in the following schema, which has cursor connections with filtering and ordering implemented.

type Query {
products(
first: Int,
after: String,
where: ProductWhereInput
order: [ProductOrderByInput!]
): ProductConnection
}

type Product {
id: ID!
upc: String!
price: Float!
inStock: Boolean!
}

type ProductEdge {
node: Product
cursor: String
}

type ProductConnection {
edges: [ProductEdge]
pageInfo: PageInfo
}

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

input ProductOrderByInput {
upc: SortOrder
price: SortOrder
inStock: SortOrder
}

input ProductWhereInput {
and: [UserWhereInput!]
or: [UserWhereInput!]
not: [UserWhereInput!]
id: IdFilterInput
upc: StringFilterInput
price: FloatFilterInput
inStock: BooleanFilterInput
}

input IdFilterInput {
equals: ID
in: [ID!]
not: IdFilterInput
notIn: [ID!]
}

input StringFilterInput {
contains: String
endsWith: String
equals: String
in: [String!]
mode: QueryMode
not: NestedStringFilterInput
notIn: [String!]
startsWith: String
}

input NestedStringFilterInput {
contains: String
endsWith: String
equals: String
in: [String!]
not: NestedStringFilterInput
notIn: [String!]
startsWith: String
}

input FloatFilterInput {
equals: Float
gt: Float
gte: Float
in: [Float!]
lt: Float
lte: Float
not: FloatFilterInput
notIn: [Float!]
}

input BooleanFilterInput {
equals: Boolean
}

enum SortOrder {
ASCENDING
DESCENDING
}

enum QueryMode {
DEFAULT
INSENSITIVE
}

Now that we have our schema we will act as a client making the following query to get a product connection:

query GetProductConnection(
$first: Int,
$after: String,
$where: ProductWhereInput,
$order: [ProductOrderByInput!]
) {
products(
first: $first,
after: $after,
where: $where,
order: $order
) {
edges: {
node: {
id
upc
inStock
price
}
}
pageInfo: {
hasNextPage
endCursor
}
}
}
{
"first": 100,
"after": null,
"where": null,
"order": [
{
"inStock": "ASCENDING"
},
{
"price": "DESCENDING"
}
]
}

As the server, we will need to resolve the cursors before returning a response to the client. To resolve a cursor we can follow these steps. For this example, we will use AES for encryption and HEX for encoding.

Step 1: Gather Arguments and Node:
To begin the cursor resolution process, we first need to obtain the relevant arguments and the node (e.g., product) for which we want to create the cursor.

{
"first": 100,
"after": null,
"where": null,
"order": [
{
"inStock": "ASCENDING"
},
{
"price": "DESCENDING"
}
]
}
{
"id": "7B20225F5F747970656E616D65223A202250726F64756374222C20226964223A202261303236653938342D636434382D343139372D386463362D32343636326265646366666522207D",
"upc": "1845678901001",
"inStock": true,
"price": 100
}

Step 2: Populate the Cursor Data Object “arg” Field:
Next, we will construct the “args” field for the Cursor Data Object, in this case, we will need the “order” fields from the connection arguments. Since ordering was applied we will need to retain the “order” argument to resolve subsequent queries. However, we can omit fields like “first”, “after”, or “where” as they won’t influence the resolution of subsequent requests.

{
"args": {
"order": [
{
"inStock": "ASCENDING"
},
{
"price": "DESCENDING"
}
]
}
}

Step 3: Populate the Cursor Data Object “ref” Field:
In this step, we populate the “ref” field for the Cursor Data Object. Here, we need to include relevant fields from the product node, such as “id,” “inStock,” and “price.” The “id” is essential as it serves as the global object identifier, while “inStock” and “price” are included because they are part of the “orderBy” argument.

{
"args": {
"order": [
{
"inStock": "ASCENDING"
},
{
"price": "DESCENDING"
}
]
},
"ref": {
"id": "7B20225F5F747970656E616D65223A202250726F64756374222C20226964223A202261303236653938342D636434382D343139372D386463362D32343636326265646366666522207D",
"inStock": true,
"price": 100
}
}

Step 4: Populate the Cursor Object “iv” Field:
Since we are using AES for data encryption, it is necessary to store the initialization vector (IV) in the “iv” field of the Cursor Object. The IV is used during the encryption and decryption process and helps enhance data security.

{
"iv": "656E636F646564206976"
}

Step 5: Populate the Cursor Object “data” Field:
Using the initialization vector and an encryption key, we will use AES to encrypt the Cursor Data Object and store its encrypted value in the “data” field of the Cursor Object.

{
"iv": "656E636F646564206976",
"data": "656E637279707465642064617461"
}

Step 6: Encode the Cursor Object:
Finally, we convert the encrypted “Cursor Object” into a HEX representation. The resulting HEX-encoded value will be added to the edges’ “cursor” field, as well as the page info “startCursor” and “endCursor” fields, making it easily consumable for clients.

7B20226976223A20223635364536333646363436353634323036393736222C202264617461223A20223635364536333732373937303734363536343230363436313734363122207D

You can now use this cursor in the next request “after” argument to get the next set of edges.

query GetProductConnection(
$first: Int,
$after: String,
$where: ProductWhereInput,
$order: [ProductOrderByInput!]
) {
products(
first: $first,
after: $after,
where: $where,
order: $order
) {
edges: {
node: {
id
upc
inStock
price
}
}
pageInfo: {
hasNextPage
endCursor
}
}
}
{
"first": 100,
"after": "7B20226976223A20223635364536333646363436353634323036393736222C202264617461223A20223635364536333732373937303734363536343230363436313734363122207D",
"where": null,
"order": [
{
"inStock": "ASCENDING"
},
{
"price": "DESCENDING"
}
]
}

Conclusion

By following this opinionated specification for creating cursors in cursor-based pagination, you can ensure efficient data retrieval and improved security in your GraphQL APIs. Remember that cursor connections empower developers to work with large datasets more effectively, providing a smoother user experience and optimized network performance. As you implement cursor connections in your GraphQL applications, keep in mind the importance of data security, payload size, and encoding for cursors, and adapt the specifications according to your specific use case and security requirements.

--

--