Efficient Pagination With Prisma

Featured image

When adding pagination to the Bytes section of my website, I didn’t want to use traditional limit/offset pagination, instead I wanted to experiment with cursor based pagination which scales significantly better than limit/offset pagination.

One of the challenges with cursor based pagination is knowing the pagination boundaries. For example, when are you at the last page? Or, if navigating back towards the beginning, how do you know when you reach the beginning? With a little creativity we can solve this problem very efficient even with a significant number of database records.

Representing state in the URL

First, let’s review the way we will store the state of the current page in the URL query string. If there are no query params, we will default to the first page of results. If there is an after=<id> query param, we will fetch the next page of results following the id specified.

/bytes?after=20230920211722

When paginating backwards, we swap the after param with a before param.

/bytes?before=20230920211722

The search query

There are two main challenges we have to solve to make the pagination work: the query itself, and managing when to enable/disable the previous/next links. Let’s start with the query which should look something like this.

type Direction = "none" | "forward" | "backward"
type SearchBytesProps = {
  before?: string
  after?: string
}

function searchBytes({ before, after }: SearchBytesProps) {
  const cursor = after ?? before
  const direction: Direction = after
    ? "forward"
    : before
      ? "backward"
      : "none"

  const res = await prisma.byte.findMany({
    cursor: cursor ? { id: cursor } : undefined,
    skip: direction === "none" ? undefined : 1,
    take: (direction === "backward" ? -1 : 1) * (PAGE_SIZE + 1),
  })
}

There are three search “directions” that are possible. The default is none indicating we are on the root page without having ever paginated through results. In this case, we don’t want to skip any items, and we pull one more than the page size. More on that in a moment.

The next direction is forward, which functionally is very similar to none, with the slight difference being that we need to skip an item.

const res = await prisma.byte.findMany({
  cursor: cursor ? { id: cursor } : undefined,
  skip: direction === "none" ? undefined : 1,
  take: (direction === "backward" ? -1 : 1) * (PAGE_SIZE + 1),
})

This is due to the fact that when we navigate to a URL like this:

/bytes?after=20230920211722

We are indicating we want to get a page of results after the item with the id of 20230920211722. Later when we build the previous/next links, we’ll use the last item in the page of results to make our link. As a result, we don’t want the query to return the last item of the previous page on the next page, since we’ve already seen that item. So, applying skip: 1 will ensure we skip to the first item after the given id, just as the query string would imply.

The other important argument is take, which specifies the number of records to return from the database query. As you can see we are taking one more record than the page size, which we will need in the next section, so more on that in just a moment.

const res = await prisma.byte.findMany({
  cursor: cursor ? { id: cursor } : undefined,
  skip: direction === "none" ? undefined : 1,
  take: (direction === "backward" ? -1 : 1) * (PAGE_SIZE + 1),
})

When the before param is passed, this logic is very similar with the slight exception that we are using a negative value for take which indicates we need to get records before the specified cursor.

Previous/next links

Now comes the fun part, generating previous/next links. If we are on the first or the last page, we want to disable our previous/next buttons since we never want a user navigating past the first or last item. This is typically quite easy with limit/offset pagination since you pull the total count which you can use to calculate if there are more items before or after the current page.

However, cursor based pagination makes this a tad more tricky. Even if we pull the total count, that doesn’t really help us since we don’t know the index of the current page of items, we just know they come before or after a given id. This is where our one extra item comes into play.

First though, we need to trim the extra item from the array of results. We can do this using slice() to either remove from the start or end of the array.

const bytes =
  direction === "backward"
    ? res.slice(-PAGE_SIZE)
    : res.slice(0, PAGE_SIZE)

Okay, it’s finally time to use that extra result that we’ve been talking about. While it’s not going to be displayed, we’ll go ahead and check to see if the number of items from the database is greater than the page size. If it was, then we know there are more items. We don’t know how many, but that doesn’t matter, we only care there is some number of results we haven’t yet seen.

const hasMore = res.length > PAGE_SIZE

This helps, but we also can make a few assumptions about our data without needing any information at all:

  • When no before/after params are specified, we are on the first page, thus there is no previous page.
  • When navigating forwards, there will always be a previous page.
  • When navigating backwards, there will always be a next page.

Armed with this information, the logic for determining the next/previous page links becomes fairly simple.

const nextHref =
  direction === "backward" || hasMore
    ? `/bytes?after=${bytes.at(-1)?.id}`
    : undefined

const prevHref =
  direction === "forward" || (direction === "backward" && hasMore)
    ? `/bytes?before=${bytes.at(0)?.id}`
    : undefined

Conclusion

So, it’s fair to say that using cursor-based pagination is definitely overkill for a simple site like mine, but the learning experience was definitely worth it. In a production system where there is extremely high volumes of data, this strategy is very effective for providing a rich pagination experience without sacrificing performance.

It’s also helpful to note that using cursor based pagination produces more consistent results. Navigating to /bytes?after=20230920211722 will always return the same set of results assuming stable sorting, but /bytes?page=2 will produce different results as content is added or removed.

I hope you enjoyed this post, if you did please feel free to share it! Cheers y’all!