Implementing Firestore Pagination with Query Cursors
Learn how to implement efficient pagination in Firebase Firestore using query cursors to avoid the performance and cost penalties of offsets.
20 Jul 2025, 03:34 UTC

The Problem: Avoiding the Offset Performance Trap
When handling large collections in Cloud Firestore, loading all documents at once causes high memory consumption on the client and excessive read costs. While some databases use an offset to skip records, Firestore's offset() method still reads the skipped documents internally. This means if you offset by 1,000 documents to reach page 11, you are billed for 1,000 reads that the user never sees.
The solution is Query Cursors. Cursors allow you to tell Firestore exactly where the previous page ended, allowing the database to jump directly to the next set of documents without scanning the preceding data.
How Query Cursors Work
Pagination in Firestore relies on a combination of orderBy(), limit(), and cursor methods. The most common method for "Next Page" functionality is startAfter(). This method takes a DocumentSnapshot—the actual object representing the last document of the current page—and uses its internal index position to find the next record.
Implementation Example (JavaScript SDK v9+)
In this example, we assume a collection of products ordered by price. We maintain the lastVisible document in the application state to track our position.
import { query, collection, orderBy, limit, startAfter, getDocs } from "firebase/firestore";
let lastVisible = null;
const PAGE_SIZE = 10;
async function fetchProductsPage() {
const productsRef = collection(db, "products");
// 1. Define the base query with ordering and limit
let q = query(
productsRef,
orderBy("price"),
limit(PAGE_SIZE)
);
// 2. If we have a cursor from the previous page, apply it
if (lastVisible) {
q = query(q, startAfter(lastVisible));
}
const documentSnapshots = await getDocs(q);
// 3. Store the last document snapshot for the next request
lastVisible = documentSnapshots.docs[documentSnapshots.docs.length - 1];
return documentSnapshots.docs.map(doc => ({ id: doc.id, ...doc.data() }));
}
Execution Details
- Where to run: Client-side application or Firebase Cloud Functions.
- Permissions: The authenticated user must have
readaccess to the collection via Firestore Security Rules. - Expected Result: The first call returns documents 1-10. The second call, using the 10th document as the cursor, returns documents 11-20.
- Risk: If the
orderByfield is not indexed or the field is missing from some documents, those documents will be excluded from the results entirely.
Critical Configuration Requirements
The Necessity of OrderBy
Cursors cannot function without a deterministic order. If you do not provide an orderBy() clause, Firestore defaults to Document ID. However, for business logic (like sorting by date or price), you must explicitly define the order. The cursor relies on the value of the sorted field to locate the starting point for the next page.
Composite Indexes
If your paginated query includes a filter (e.g., where("category", "==", "electronics")) and a different sort field (e.g., orderBy("price")), Firestore requires a Composite Index. Without this, the query will fail. You can find the required index link in the Firebase SDK error message in the browser console; clicking it will automatically create the index in the Firebase Console.
Limitations and Common Mistakes
| Mistake | Consequence | Correct Approach |
|---|---|---|
Using offset() for deep paging |
Increased latency and higher billing costs. | Use startAfter(documentSnapshot). |
| Passing a value instead of a snapshot | Potential for duplicate results if multiple docs have the same value. | Pass the entire DocumentSnapshot to startAfter(). |
| Ignoring index creation | Query returns a "Failed Precondition" error. | Check the "Indexes" tab in the Firebase Console. |
Handling Duplicate Values
If you use startAt() or startAfter() with a specific value (e.g., a price of 19.99) rather than a document snapshot, and multiple documents share that same price, you may see the same document appear on both pages. Using the DocumentSnapshot ensures the cursor is unique to that specific record, regardless of shared field values.
Verification Steps
- Count Check: Run the query with
limit(10)and verify that exactly 10 documents are returned in the first batch. - Cursor Continuity: Capture the ID of the 10th document. Run the second page query and verify the 11th document is the first item returned, and the 10th document is not repeated.
- Index Validation: Navigate to Firebase Console > Firestore Database > Indexes to ensure the composite index for your filter/sort combination is marked as "Enabled".
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.