Stop Using Manual Offsets in Firestore: A Guide to Cursor-Based Pagination
Stop paying for documents you don't display. Learn how to implement cursor-based pagination in Firestore using startAfter() to reduce read costs and improve latency.
12 Mar 2026, 14:24 UTC

The Cost of Skipping Documents
In traditional SQL databases, pagination is often handled via an OFFSET clause. You tell the database to skip the first 100 rows and give you the next 20. In Google Cloud Firestore, this approach is a performance and financial trap. Because Firestore is a NoSQL document store, it doesn't have a built-in offset parameter. If you attempt to simulate an offset by fetching all documents and discarding the first 100 in your application code, you are still billed for those 100 reads.
The solution is cursor-based pagination. Instead of telling the database how many documents to skip, you provide a pointer (a document snapshot) to the last item of the previous page. The database jumps directly to that point and fetches the next set of results, ensuring you only pay for the documents you actually display.
Implementing the Cursor Pattern
To implement this, you must combine three specific query methods: orderBy(), limit(), and startAfter(). The orderBy() clause is mandatory because the database needs a deterministic sequence to know exactly where the "next" document begins.
When a user requests the first page, you execute a query with a limit. When they request the next page, you pass the last document snapshot from the previous result set into the startAfter() method. This tells Firestore to begin the scan immediately following that specific document.
Handling Non-Unique Fields
A common mistake occurs when paginating by a field that isn't unique, such as a timestamp or a category. If multiple documents have the exact same timestamp, Firestore may skip documents or return them out of order because the cursor isn't specific enough.
To fix this, you must add a second orderBy() clause using the document ID (which is always unique). This creates a tie-breaker that ensures every single document has a unique position in the sequence.
Worked Example: Paginated Message Feed
Assume we are building a chat application using Firestore (v9+ Modular SDK). We want to fetch messages in descending order of time, 20 messages per page.
import { query, collection, orderBy, limit, startAfter, getDocs } from "firebase/firestore";
async function fetchMessages(lastVisibleDoc = null) {
// 1. Reference the collection
const messagesRef = collection(db, "messages");
// 2. Build the query
// We order by timestamp, then by __name__ (doc ID) to ensure uniqueness
let q = query(
messagesRef,
orderBy("timestamp", "desc"),
orderBy("__name__", "desc"),
limit(20)
);
// 3. If we have a cursor from the previous page, start after it
if (lastVisibleDoc) {
q = query(q, startAfter(lastVisibleDoc));
}
const documentSnapshots = await getDocs(q);
// Return the documents and the last snapshot for the next call
return {
data: documentSnapshots.docs.map(doc => doc.data()),
lastVisible: documentSnapshots.docs[documentSnapshots.docs.length - 1]
};
}
Execution Context: This code runs in the client-side application or a Node.js environment with the Firebase Admin SDK. You will need read permissions on the "messages" collection.
Trade-offs and Limitations
| Feature | Cursor-Based | Manual Offset (Simulated) |
|---|---|---|
| Read Cost | Fixed (Limit size) | Linear (Increases per page) |
| Random Access | Impossible (No "Jump to Page 10") | Possible |
| Consistency | High (Stable pointers) | Low (Items shift as new data arrives) |
The primary limitation of this approach is the loss of random access. You cannot jump directly to page 50 without having the cursor from page 49. For most modern mobile and web interfaces, this is an acceptable trade-off, as "infinite scroll" or "load more" patterns are the standard.
Verification and Testing
To verify your implementation is working efficiently, check the following:
- Billing Check: Monitor the Firebase Console Usage tab. If you fetch 20 items on page 10 and your read count increases by 20 (rather than 200), your cursor is working.
- Duplicate Check: Ensure you are using
startAfter()and notstartAt(). UsingstartAt()will include the cursor document in the new result set, causing the last item of page 1 to appear as the first item of page 2. - Stability Check: If you are ordering by a non-unique field, verify that no documents are missing when multiple documents share the same value.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.