Why pagination?
GraphQL enables developers to fetch all necessary data fields from the server in a single query, eliminating any unnecessary overhead in terms of network response time and payload size. However, these optimizations are not always guaranteed, especially when dealing with lists of structured data of unknown lengths. Querying for an entire list that can contain an infinite number of elements may result in delayed responses with enormous payloads. This is entirely avoidable with pagination, or querying portions of a list at a time.
The basics
If you're familiar with GraphQL, you should know that querying for a list
type data field returns the entire list. This is simple but not recommended for lengthy lists, especially when the list includes unneeded data.
{
teacher {
name
students {
name
}
}
}
returns the teacher's name and a list of the teacher's students' names
students
is the only data field with a list
type. Let's take a look at its resolver.
async function students(obj, args, context, info) {
return context.db.students().where('id', 'IN', obj.studentIDs).fetchAll()
}
In many cases, querying for all the elements within a list is unnecessary. You may only need to know the names of the first 5 students, for example. This can be achieved through slicing.
Slicing is implemented by passing an argument to the resolver belonging to students
. We'll name the argument first
.
{
teacher {
name
students(first: 5) {
name
}
}
}
returns the teacher's name and a list of the teacher's first 5 students' names
The students
resolver is slightly modified to take into account the first
argument.
async function students(obj, args, context, info) {
return context.db
.students()
.where('id', 'IN', obj.studentIDs.slice(0, args.first))
.fetchAll()
}
Although this gives us control over the number of elements to query for from a list, this is not pagination as it doesn't provide the ability to make followup queries. For example, how would we query for the second set of 5 students?
Approaches to pagination
There are multiple ways of implementing pagination, each with its pros and cons. Depending on your use case you may choose between offset
, id
, or cursor
based approaches.
Offset-based pagination
Two arguments are used for this approach: limit
and offset
.
limit
holds the number of elements to return from the listoffset
holds the starting index of the list at which to start returning elements from
Example: students (limit:5 offset:10)
returns a list of 5 students starting at position 10 of the list.
async function students(obj, args, context, info) {
return context.db
.students()
.where(
'id',
'IN',
obj.studentIDs.slice(args.offset, args.offset + args.limit)
)
.fetchAll()
}
To send a followup query to fetch the next set of 5 students,
currentOffset += 5
await fetchTeacherAndStudentNames({
variables: { limit: 5, offset: currentOffset },
})
This pagination approach is simple to implement. However, the primary drawback is that it's highly susceptible to any changes in the list. Any insertion or deletion operations on the list may lead to the skipping of elements and inconsistencies in ordering.
Id-based pagination
Similar to offset-based pagination, this approach works similarly with the exception of the afterID
argument replacing offset
.
afterID
holds a unique identifier for the last element in the list from the previous query
Example: the following query returns a list of 5 students immediately following the student with id: 152
.
students (limit:5, afterID:152) {
id
name
}
async function students(obj, args, context, info) {
const startIndex = args.afterID
? obj.studentIDs.findIndex((studentID) => args.afterID === studentID) + 1
: 0
return context.db
.students()
.where(
'id',
'IN',
obj.studentIDs.slice(startIndex, startIndex + args.limit)
)
.fetchAll()
}
To send a followup query to fetch the next set of 5 students,
const students = queryData.teacher.students
const lastStudentID = students[students.length - 1].id
await fetchTeacherAndStudentNames({
variables: { limit: 5, afterID: lastStudentID },
})
The advantage to this approach is its simplicity and consistency. Queries using this technique always return the elements after the last fetched element regardless of list insertion/deletion operations.
Cursor-based pagination
Cursor-based pagination is the most powerful approach due to the flexibility it brings if the pagination model ever changes. Yet, it is the most complex to understand and implement.
Example: students (limit:5, afterCusor:$cursor)
returns a list of 5 students immediately following a cursor which marks a position in the list. The cursor is returned from a prior query.
A cursor is a unique identifier for an item within a list and is usually base64 encoded. Imagine our data represented in a graph. The data naturally belongs in the nodes, but where does the cursor belong? The cursor represents a connection between our data objects, and thus should belong in an edge.
Besides what was just mentioned, we want more answers from the server in order to paginate efficiently. Are there more items in the list we may want to query next? To answer this, we'll need to update our students
resolver to return an object representation of a page instead of returning a student
list directly. The page will contain a page-cursor and edges. Edges will be a list with each edge containing a node and edge-cursor. Lastly, we'll be able to access student data from within node
.
Query:
{
teacher {
name
students (limit: 5, cursor: '5GsHwYA62') {
edges {
node {
name
}
cursor
}
pageInfo {
endCursor
hasNextPage
}
}
}
}
Resolver:
async function students(obj, args, context, info) {
const startIndex = args.cursor
? obj.studentIDs.findIndex((studentID) => atob(args.cursor) === studentID) +
1
: 0 // decode the cursor from base64
const students = await context.db
.students()
.where(
'id',
'IN',
obj.studentIDs.slice(startIndex, startIndex + args.limit)
)
.fetchAll()
const edges = students.map((student) => ({
node: student,
cursor: btoa(student.id),
})) // set cursor as student.id encoded in base64
return {
edges,
pageInfo: {
endCursor: edges[edges.length - 1].cursor,
hasNextPage: startIndex + args.limit < obj.studentIDs.length,
},
}
}
By restructuring the response of our students
resolver, we know whether additional pagination requests are necessary while formatting our data in a way that more accurately transforms a list into a number of pages.
Summary
We've covered various approaches to pagination and how important it is to make the most out of GraphQL. By implementing these techniques into your code, you can worry less about overhead and inefficient code!
We've applied these practices to our code at Anvil, and believe sharing our experience helps everyone in creating awesome products. If you're developing something cool with PDFs or paperwork automation, let us know at developers@useanvil.com. We’d love to hear from you.