Efficient Firestore Pagination with Query Cursors
Learn how to build efficient paginated queries in Cloud Firestore using query cursors. The article walks through a JavaScript example, explains required indexes, and highlights common pitfalls to avoid.
22 Feb 2026, 00:53 UTC

Why Pagination Matters
When a client loads a large collection—say, a news feed or product catalog—reading all documents at once is expensive in terms of bandwidth, latency, and cost. Firestore’s query cursors let you fetch a subset of documents, called a page, and then continue where you left off. The key benefit is that each request reads only the documents you need, keeping read counts low and providing a smooth user experience.
How to Build a Paginated Query
The core idea is simple: order the collection on a field that changes (e.g., createdAt), fetch the first pageSize documents, and remember the last document. For subsequent pages, use that document as a cursor with startAfter() or startAt().
Step‑by‑Step Example (JavaScript)
// Assuming Firebase v9 modular SDK
import { getFirestore, collection, query, orderBy, limit, startAfter, getDocs } from "firebase/firestore";
const db = getFirestore();
const PAGE_SIZE = 20;
// 1. Fetch the first page
async function fetchFirstPage() {
const q = query(
collection(db, "posts"),
orderBy("createdAt", "desc"), // newest first
limit(PAGE_SIZE)
);
const snapshot = await getDocs(q);
const lastDoc = snapshot.docs[snapshot.docs.length - 1];
return { docs: snapshot.docs, lastDoc };
}
// 2. Fetch the next page using the last document as a cursor
async function fetchNextPage(lastDoc) {
const q = query(
collection(db, "posts"),
orderBy("createdAt", "desc"),
startAfter(lastDoc), // use the snapshot from the previous page
limit(PAGE_SIZE)
);
const snapshot = await getDocs(q);
const newLastDoc = snapshot.docs[snapshot.docs.length - 1];
return { docs: snapshot.docs, lastDoc: newLastDoc };
}
Run fetchFirstPage() once to load the initial 20 posts. Keep the returned lastDoc in state. When the user scrolls to the bottom, call fetchNextPage(lastDoc) to load the next 20.
Key Points in the Code
- Ordering field: Must be indexed. If you order by
createdAt, Firestore creates a single‑field index automatically. - Unique tie‑breaker: If many posts share the same
createdAt, add a secondary sort by document ID:
This guarantees a deterministic order and prevents duplicate or missing items when timestamps collide.orderBy("createdAt", "desc"), orderBy("__name__", "desc") - Composite index: Combining
orderBy,limit, andstartAfterrequires a composite index. Firestore will show an error with a link to create it automatically in the console or viafirebase init hosting. - Permissions: The calling user must have
readpermission on the collection. In a security rule, you might allow:match /posts/{postId} { allow read: if true; // replace with proper auth }
Limitations and Common Mistakes
- Non‑unique cursor field: Without a tie‑breaker, two documents with the same
createdAtcan cause one to be omitted or duplicated across pages. Always add__name__as a secondary order when values may repeat. - Stale snapshots: If a document is updated or deleted between page loads, the cursor may point to a non‑existent document. Refresh the snapshot by re‑reading the last document or use a transaction to capture a consistent snapshot.
- Changing the query: Adding a filter or changing the order invalidates any previously stored cursors. Store the cursor only for the exact query that produced it.
- Pagination depth: Large collections can still incur many read operations if you fetch many pages. Consider using a “load‑more” button rather than infinite scroll, or pre‑fetch the next page in the background.
- Missing index: Firestore will reject the query with an error code
failed-preconditionand a URL to create the necessary composite index. Don’t ignore this; without the index the query will not run.
Verification Checklist
- Run the sample code in a test Firestore instance.
- Confirm each page returns
PAGE_SIZEdocuments, except possibly the last page. - Check the Firestore Usage tab: the number of read operations should equal
PAGE_SIZE × number_of_pages + 1(the +1 comes from reading the cursor document). - Test with the Firebase Emulator Suite to simulate concurrent writes and ensure pagination remains consistent.
Practical Tips for Production
- Cache the last document ID locally to avoid re‑fetching the same cursor.
- Use the
getDocs()result’smetadata.fromCacheproperty to detect stale data when the network is flaky. - For very large datasets, consider adding a
lastVisiblefield that stores the last document’s ID and use it as a cursor stored in a separate collection. - Monitor read costs: Firestore charges per document read, so limiting
PAGE_SIZEto 20–50 is usually a good balance.
Conclusion
Query cursors give you fine‑grained control over Firestore pagination. By ordering on a unique field, adding a tie‑breaker, and handling composite indexes, you can fetch pages efficiently, keep costs low, and provide a responsive UI. Just remember to keep the cursor state in sync with the query and to refresh snapshots when concurrent writes occur.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.