AGS Logo AGS Logo

Realtime Navigation
Pagination for Firebase RTDB

A book laying on a able with all of the pages fanned out. The perspective is from the bottom of the book to maximize the fan effect.

Photo by Olga Tutunaru on Unsplash

I had a situation where we’re using Firebase RTDB for a project and needed to display a paginated table of results, sorted by date with the newest items first. This is a fairly normal request, however with RTDB it’s a bit of a challenge given the unique cursor-based approach and somewhat sparse documentation. Additionally, not a single one of the 20+ sources I found online had code that actually achieved the desired result.

The Solution

After entirely too many hours of work, I present to you a functional solution to the pagination problem.

The approach assumes that we’re tracking a cursor record that in general is the first record of the next page (from the user’s perspective). This means when moving next the former page’s cursor is the first record of our results, and when moving previous (backward) the former page’s cursor is an entire page away from us. Also, the last page of results may not exactly match our page size (for example, 2 records on the last page with a page size of 3). This means that the location of the cursor relative to the end of the page changes for the last page.

Project Setup

You’ll need the following setup in your code for this to work:

private cursor = { val: 0, key: null }
private path = '' // The database path to query
private pageSize = 10 // Size for pagination
private page = 1 // Page number (start at 1)
private list = [] // Your data
// UI Helpers
private hasNext = false
private hasPrev = false

constructor(
  // I'm using Angular but you can use any SDK for this
  private db: AngularFireDatabase
) {}

Additionally, you’ll need to be sure to have an index so this code can be fast. This will look something like this:

"collectionName": {
  ".indexOn": ["date"],
  "$id": {
    ".read": "auth !== null"
  }
}

It’s very important that the index is at the collection level, not the document level.

Initial Query (for populating the first page)

To better illustrate the previous explanation, we have an array of values from 1 to 17 representing increasing dates (1 is the oldest, 17 is the most recent). Because of this our next and previous arrows are backward from what we would expect in a UI. We're taking pages from right (newest) to left (oldest) and marking a cursor for calculation of subsequent pages.

First Page. Shows a list of 17 numbers in a sequence with 1 on the left and 17 on the right. The numbers are outlined with squares to indicate that they represent units of data. A Next arrow points left from the 1 and a Previous arrow points right from the 17. The page size is 3 and the first page of results is outlined which includes 15, 16, and 17. The cursor is marked as 14 and the query results are bracketed as containing 14 through 17 (the cursor and the page).

We run the query using limitToLast because we want the results at the end of the list, not the beginning, since we want recent records first. Despite this, we also have to reverse() the returned results so that the newest items will show at the top instead of the bottom.

async firstPage() {
  const snapshot$ = this.db.list(this.path, ref =>
    ref.orderByChild('date').limitToLast(this.pageSize + 1)
  ).snapshotChanges()
  const snapshot = await firstValueFrom(snapshot$)
  this.cursor = { val: snapshot[0].payload.val().date, key: snapshot[0].key }
  const _list = snapshot.map(snap => snap.payload.val()).reverse()
  if(_list.length > this.pageSize) {
    _list.pop()
    this.hasNext = true
  }
  this.list = _list
}

Note that we saved the cursor before reversing the order of the list. Also, we’re intentionally querying an extra record for UX reasons, so that we can know if there will be another page of results after this one. Assuming we returned more than a full page of results, we pop off the extra record and update the hasNext indicator.

Moving Next

To move to the next page of results, you’re actually moving backward through the data as sorted by the index. This means we’re ending our query at the first page of results (rather than starting from that page), which is counter-intuitive. The following image uses dotted outlines to show where we had been, solid outlines to indicate the new state, and arrows to show the movement of the cursor.

Next Page. Shows the same list of 17 numbers from the First Page with the same page size and directional arrows. The list of numbers is displayed twice with different call-outs. The first list is labeled Normal and represents navigation from the first page to the next page. The cursor had been on 14 and has moved to 11. The page had contained 15 through 17 but now contains 12 through 14, and the query bracket contains 11 through 14. The second list is labeled Last Page and represents navigation from the next-to-last page to the very last page, which has just two items instead of three. Arrows show how the cursor has moved  from the first page from 14 to 11 to 8 to 5 to 2 and now finally to 1, the first element in the list. The page had contained 3 through 5 but now contains 1 and 2, and the query bracket contains 1 and 2.

As you can see, the last page is a bit unique as it's the only page to contain the cursor as part of the data, and also to include fewer items than the page size (this is more common than not in paginated lists). We make this work with the following code.

async next() {
  if(!this.hasNext) return // Shortcut
  this.page++
  this.hasPrev = true // Since we're moving forward

  const snapshot$ = this.db.list(this.path, ref =>
    ref.orderByChild('date')
       .endAt(this.cursor.val, this.cursor.key) // MAGIC
       .limitToLast(this.pageSize + 1)
  ).snapshotChanges()
  const snapshot = await firstValueFrom(snapshot$)
  this.cursor = { val: snapshot[0].payload.val().date, key: snapshot[0].key }
  const _list = snapshot.map(snap => snap.payload.val()).reverse()
  if(_list.length > this.pageSize) {
    _list.pop()
    this.hasNext = true
  } else {
    this.hasNext = false
  }
  this.list = _list
}

You’ll notice that this is very similar to the first() function but it has two additional requirements:

  1. We have to add the endAt to progress to the next page of results
  2. We need to be sure to set hasNext = false if we’re at the end of the result set

Moving Previous

To move backward, we really throw things on their head. First, we need to hold on to the number of items in the former page so that we can account for the possibility that we were on the last page of results and it wasn’t a full page (fewer results than our page size). We also have to reverse direction in relation to our cursor, and fetch the former page of results again along with the new page of results.

NOTE: This is the trick I found that actually works for moving backwards. I haven't been able to find this technique published anywhere else.

Previous Page. Similar to the Next Page diagram shows the same two lists of 17 numbers with the same page size, directional arrows, and labels. The Normal list represents navigation from the third page back to the second page. The cursor had been on 8 and has moved to 11. The page had contained 9 through 11 but now contains 12 through 14, and the query bracket contains 8 through 14, a total of 7 elements. The Last Page list represents navigation from the last page to the page just before it, with the last page containing just two items instead of three. The cursor moves from 1 to 2, and the page which had contained 1 and two now contains 3 through 5. The query bracket contains 1 through 5, a total of 5 elements.

This diagram helps illustrate why we need to capture more records when moving previous. We always need to include the prior cursor, the new cursor, and the page of results we want to display.

NOTE: We could reduce the extra records by tracking separate cursors for next/previous but in practice I found this was harder to understand without improving the code at all. Because navigation forward through a list is more common than backward, and the prior page of results are likely to be cached, this shouldn't have significant performance implications.

The actual code behind this concept does a bit of extra math to remove the results we're returning but not displaying, and to account for the last page being smaller than a full page.

async prev() {
  if(!this.hasPrev) return // Shortcut
  this.page--
  this.hasPrev = this.page > 1
  const _lastPageSize = this.list.length

  const snapshot$ = this.db.list(this.path, ref =>
    ref.orderByChild('date')
       .startAt(this.cursor.val, this.cursor.key) // MAGIC
       .limitToFirst(this.pageSize + _lastPageSize + 1) // LOOK
  ).snapshotChanges()
  const snapshot = await firstValueFrom(snapshot$)
  const _list = snapshot.map(snap => (snap => ({...snap.payload.val(), key: snap.key})).reverse()
  // Substract 1 if the last page wasn't a full page
  const excessRecords = _lastPageSize - (_lastPageSize < this.pageSize ? 1 : 0)
  _list.splice(-excessRecords, excessRecords) // Remove the extra results
  this.cursor = { val: _list[_list.length-1].date, key: _list[_list.length-1].key }
  _list.pop() // Remove our cursor record
  this.query.hasNext = true
  this.list = _list
}

Because we’re reversing direction we use startAt and limitToFirst. We have to be sure to remove the previous page of results from our list, but also account for the cursor record. The cursor is NOT inside our desired results for this page, but if the page we just navigated away from was a partial page then the cursor was within the list of results for that page. We need to be sure not to remove the cursor from results until after we’ve saved it for future use.

User Interface

With this implementation you can do a few UI niceties:

  • Display the current page number
  • Disable Previous / Next buttons if it’s not possible to move in that direction
  • Display the page size

Of course, you can’t display the total number of pages with this approach as we don’t have that information, and you also can’t easily allow them to change page size without resetting back to the beginning.

I hope you find this breakdown helpful. If you have an actual, working example that is simpler than this I would love to hear about it. If this solves a problem for you, I would love to hear that too!

Firebase

Firebase provides the backend platform you need to support your PWA or SPA without the need to provision hardware, and you only pay for what you use. It can complement your existing API layer or can be a full backend replacement.

License: CC BY-NC-ND 4.0 (Creative Commons)