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.
