Engineering

Implementing pagination in GraphQL

Author headshot
By Winggo Tse

Using GraphQL and dealing with list structured data? Learn about implementing pagination to ensure speedy network responses and retrieving the right amount of data only when you need it.

Back to all articles
Implementing pagination in GraphQL

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 list
  • offset 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.

Get a Document AI demo (from a real person)

Request a 30-minute demo and we’ll be in touch soon. During the meeting our team will listen to your use case and suggest which Anvil products can help.
    Want to try Anvil first?
    Sign up for free or try it now with any document.
    Want to try Anvil first?
    Sign up for free or try it now with any document.