Mastering Firestore Cursor‑Based Pagination for Scalable List UIs
Learn how to implement Firestore cursor‑based pagination, avoid gaps or overlaps, and balance read costs for large datasets. Step‑by‑step example included.
14 Jul 2025, 16:48 UTC

Problem: Paging a Large Firestore Dataset
When a front‑end needs to display a long list—say, thousands of user posts—simple “offset” pagination (skip N, take M) is tempting because it’s familiar from SQL. Firestore, however, does not support offset semantics and will return duplicate or missing items if you try to emulate it with
startAt and limit incorrectly. The result is a broken UI and wasted reads.
Thesis: Cursor‑Based Pagination is the Firestore‑Friendly Choice
Firestore’s cursor methods—startAt, startAfter, endAt, endBefore—paired with limit give a deterministic, gap‑free paging strategy. Each page is a snapshot of the database at a specific ordering point, and the next page begins exactly where the previous left off, regardless of concurrent writes.
1. Core Concepts
- orderBy – Determines the sort order of the results. Every cursor query must include the same
orderByfields. - limit – The maximum number of documents returned in one query. All reads count against Firestore’s quota, not just the page size.
- cursor (document snapshot) – A reference to the last document of the current page. It’s passed to
startAfterfor the next page. - composite index – If you order by more than one field, Firestore requires a composite index. The console will prompt you to create one if it’s missing.
2. Worked Example (Web SDK)
Assume a collection posts with fields createdAt (timestamp) and authorId. We want 10 posts per page, sorted by creation time descending.
// 1️⃣ Import and reference the collection
const postsRef = firebase.firestore().collection('posts');
// 2️⃣ Build the first page query
const firstPageQuery = postsRef
.orderBy('createdAt', 'desc')
.limit(10);
// 3️⃣ Execute and capture the last document as the cursor
firstPageQuery.get().then(snapshot => {
const posts = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
const lastDoc = snapshot.docs[snapshot.docs.length - 1];
console.log('First page:', posts);
// Store lastDoc somewhere (state, localStorage, etc.) for the next page
// 4️⃣ Build the next page query using the cursor
const nextPageQuery = postsRef
.orderBy('createdAt', 'desc')
.startAfter(lastDoc)
.limit(10);
// 5️⃣ Fetch the second page
nextPageQuery.get().then(secondSnap => {
const nextPosts = secondSnap.docs.map(doc => ({ id: doc.id, ...doc.data() }));
console.log('Second page:', nextPosts);
});
});
Key points:
- Both queries use the same
orderByclause. - We pass
lastDoctostartAfter, guaranteeing no overlap or gaps. - Each
get()call reads exactly the documents returned bylimit, plus the document used as the cursor (which is already part of the page).
3. Trade‑Offs & Limitations
- Read Cost – Every
get()counts the number of documents retrieved, even if you only need a few. For a 10‑document page, 10 reads per page, but if you add awhereclause that matches many documents, the cost can spike. - Index Requirement – Composite indexes are mandatory for multi‑field ordering. Missing indexes surface as a clear error; create them via the console or
firebase init firestore. - No Offset Support – You cannot jump to “page 5” directly. You must sequentially iterate from the first page to the desired offset, or store cursors for each page you’ve visited.
- Ordering Changes – If the field used in
orderBychanges (e.g., a newscorefield becomes primary), existing cursors become invalid. Plan for occasional re‑indexing. - Concurrent Writes – New documents added before the cursor will appear on subsequent pages; deletions before the cursor do not affect the current page but may cause “holes” if you rely on a fixed number of items.
4. Actionable Guidance
- Pick a stable ordering field—prefer timestamps or monotonically increasing IDs.
- Define page size early—balance UX (e.g., 10–20 items) against read cost.
- Handle index errors gracefully—display the console’s suggested index URL or log the error for devs.
- Persist cursors—store the last document snapshot ID or serialized snapshot in client state or localStorage if you need to resume paging later.
- Monitor read usage—use the Firebase console’s “Read” metric per query to validate that your page size aligns with budget.
- Consider Cloud Functions for per‑user limits—if you need to enforce that a user can only view 100 posts, wrap the query in a callable function that tracks the count.
By following this pattern, you ensure a smooth, scalable list UI that remains consistent even as users add or delete posts concurrently. The only state you need to keep is the last document snapshot, which keeps your server side stateless and cost‑effective.
Conclusion
Cursor‑based pagination is Firestore’s built‑in solution for reliable, gap‑free paging. It requires a stable ordering field and a composite index but pays off with predictable read costs and resilience to concurrent changes. Use the example above as a starting point, adjust the page size to your UX and budget, and you’ll have a robust list UI that scales with your data.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.