Firestore Pagination

Pagination breaks a large collection into pages so your app loads a small batch of documents at a time. Loading all documents at once is slow, expensive in terms of Firestore reads, and uses unnecessary bandwidth. Firestore provides cursor-based pagination that is efficient and works well with real-time data.

The Book Chapter Analogy

A 1000-page book is unreadable if you try to read all pages at once. You read one chapter at a time, remembering where you stopped (the last page number of each chapter). Pagination works the same way — you remember where you stopped (the last document you loaded) and use it as the starting point for the next batch.

Basic Pagination Setup

import {
  collection, query, orderBy, limit,
  startAfter, getDocs
} from "firebase/firestore";
import { db } from "./firebase";

const PAGE_SIZE = 10;
let lastVisibleDocument = null;

async function loadFirstPage() {
  const q = query(
    collection(db, "posts"),
    orderBy("publishedAt", "desc"),
    limit(PAGE_SIZE)
  );

  const snapshot = await getDocs(q);

  // Remember the last document for the next page
  lastVisibleDocument = snapshot.docs[snapshot.docs.length - 1];

  return snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
}

async function loadNextPage() {
  if (!lastVisibleDocument) return [];

  const q = query(
    collection(db, "posts"),
    orderBy("publishedAt", "desc"),
    startAfter(lastVisibleDocument),
    limit(PAGE_SIZE)
  );

  const snapshot = await getDocs(q);
  lastVisibleDocument = snapshot.docs[snapshot.docs.length - 1];

  return snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
}

Detecting the Last Page

When a page returns fewer documents than the page size, you have reached the end of the collection:

async function loadNextPage() {
  if (!lastVisibleDocument) return { posts: [], hasMore: false };

  const q = query(
    collection(db, "posts"),
    orderBy("publishedAt", "desc"),
    startAfter(lastVisibleDocument),
    limit(PAGE_SIZE)
  );

  const snapshot = await getDocs(q);

  if (snapshot.empty) {
    return { posts: [], hasMore: false };
  }

  lastVisibleDocument = snapshot.docs[snapshot.docs.length - 1];
  const hasMore = snapshot.docs.length === PAGE_SIZE;
  const posts = snapshot.docs.map((d) => ({ id: d.id, ...d.data() }));

  return { posts, hasMore };
}

Load More Button Pattern

<div id="posts-list"></div>
<button id="load-more-btn">Load More</button>
// On page load
const { posts, hasMore } = await loadFirstPage();
renderPosts(posts);
if (!hasMore) {
  document.getElementById("load-more-btn").style.display = "none";
}

// On button click
document.getElementById("load-more-btn").addEventListener("click", async () => {
  const { posts, hasMore } = await loadNextPage();
  renderPosts(posts); // append new posts to existing list
  if (!hasMore) {
    document.getElementById("load-more-btn").style.display = "none";
  }
});

Infinite Scroll Pattern

Trigger loading the next page when the user scrolls near the bottom of the page:

let loading = false;

window.addEventListener("scroll", async () => {
  const nearBottom =
    window.innerHeight + window.scrollY >= document.body.offsetHeight - 200;

  if (nearBottom && !loading) {
    loading = true;
    const { posts, hasMore } = await loadNextPage();
    renderPosts(posts);
    loading = false;

    if (!hasMore) {
      window.removeEventListener("scroll", arguments.callee);
    }
  }
});

Paginating with Real-Time Listeners

Combine pagination with onSnapshot for live-updating paginated lists. Attach a listener to each page's query and update the UI when documents in that range change:

function subscribePage(afterDoc) {
  const q = afterDoc
    ? query(collection(db, "posts"), orderBy("publishedAt", "desc"),
        startAfter(afterDoc), limit(PAGE_SIZE))
    : query(collection(db, "posts"), orderBy("publishedAt", "desc"),
        limit(PAGE_SIZE));

  return onSnapshot(q, (snapshot) => {
    const posts = snapshot.docs.map((d) => ({ id: d.id, ...d.data() }));
    renderPage(posts);
  });
}

Going Back to a Previous Page

To support backwards navigation, store each page's starting document in an array and use startAt with the stored cursor:

const pageStartDocs = []; // stack of page start cursors

async function goToPreviousPage() {
  pageStartDocs.pop(); // remove current page cursor
  const prevStart = pageStartDocs[pageStartDocs.length - 1];

  const q = prevStart
    ? query(collection(db, "posts"), orderBy("publishedAt", "desc"),
        startAt(prevStart), limit(PAGE_SIZE))
    : query(collection(db, "posts"), orderBy("publishedAt", "desc"),
        limit(PAGE_SIZE));

  const snap = await getDocs(q);
  lastVisibleDocument = snap.docs[snap.docs.length - 1];
  return snap.docs.map((d) => ({ id: d.id, ...d.data() }));
}

Key Takeaway

Firestore pagination uses startAfter with the last visible document as a cursor to fetch the next batch. Store the last document from each page and pass it to the next query. Detect the final page when the result count is less than the page size. Implement either a load-more button or infinite scroll depending on your app's UX needs. Use a cursor stack to support backwards navigation between pages.

Leave a Comment

Your email address will not be published. Required fields are marked *