Stop Using Offsets: Mastering Firestore Pagination with Query Cursors
Stop paying for skipped documents. Learn how to implement efficient, scalable pagination in Firebase Firestore using query cursors instead of offsets.
03 Oct 2025, 13:25 UTC

The Cost of the 'Skip' Mentality
When building a feed or a list, the instinct is to use offset-based pagination (e.g., OFFSET 20 LIMIT 10). In traditional SQL databases, this is standard. In Firebase Cloud Firestore, however, there is no native offset parameter. While you can technically skip documents on the client side, Firestore still charges you for every document read to reach that offset.
If a user navigates to page 100 of a list with 10 items per page, you aren't just paying for 10 reads; you are paying for 1,000. To scale a production app without a linear increase in costs and latency, you must use Query Cursors.
How Query Cursors Actually Work
A cursor is essentially a pointer to a specific document in a sorted result set. Instead of telling Firestore to "skip X documents," you tell it to "start reading immediately after this specific document."
For this to work, two conditions must be met:
- Consistent Ordering: You must use
orderBy(). Without a defined sort order, Firestore cannot determine what comes "after" a document. - Snapshot Persistence: The client must store the
DocumentSnapshotof the last item from the previous page to pass it into the next request.
Implementing a Paginated Feed
The following example demonstrates a common pattern for fetching a list of posts ordered by timestamp. This assumes you are using the Firebase JS SDK v9+ (Modular).
import {
collection,
query,
orderBy,
limit,
startAfter,
getDocs
} from "firebase/firestore";
let lastVisible = null;
const PAGE_SIZE = 10;
async function fetchNextPage(db) {
const postsRef = collection(db, "posts");
// 1. Build the base query
let q = query(
postsRef,
orderBy("createdAt", "desc"),
limit(PAGE_SIZE)
);
// 2. If we have a cursor, start after the last document
if (lastVisible) {
q = query(q, startAfter(lastVisible));
}
const documentSnapshots = await getDocs(q);
// 3. Store the last document for the next call
lastVisible = documentSnapshots.docs[documentSnapshots.docs.length - 1];
return documentSnapshots.docs.map(doc => ({ id: doc.id, ...doc.data() }));
}Execution Details
- Permissions: The user must have
readaccess to the collection via Security Rules. - Placeholders: Replace
"posts"with your collection ID and"createdAt"with your sorting field. - Risk: If
lastVisibleis null on the first call, the query starts at the beginning of the collection.
The Trade-offs of Cursor-Based Pagination
While cursors solve the cost and performance problem, they introduce specific engineering constraints:
| Feature | Offset-Based | Cursor-Based |
|---|---|---|
| Random Access | Easy (Jump to Page 5) | Hard (Must fetch 1-4 first) |
| Read Costs | High (Pays for skipped docs) | Low (Pays only for returned docs) |
| Stability | Shifts if items are deleted | Stable relative to the cursor doc |
The "Jump to Page" Limitation: You cannot jump directly to page 10 without having the snapshot of the last document on page 9. If your UI requires a numbered pagination bar, you may need to cache snapshots or reconsider if a "Load More" or infinite scroll pattern is more appropriate for your data model.
Verification and Indexing
To verify your implementation is working efficiently, monitor the Usage tab in the Firebase Console. If you fetch 10 documents, the read count should increase by exactly 10, regardless of whether you are on page 1 or page 100.
Note on Composite Indexes: If you combine a cursor with a filter (e.g., where("status", "==", "published")) and an orderBy(), Firestore will require a composite index. The SDK will provide a clickable link in the browser console error to generate this index automatically.
Actionable Summary
To implement scalable pagination in Firestore: use orderBy() to define the sequence, limit() to constrain the payload, and startAfter() passing the last DocumentSnapshot to move the window forward. Avoid any logic that simulates offsets via client-side slicing, as this will inflate your billing.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.