Handling Firestore Query Pagination in a Scalable Way

Sunday, February 2, 2025

As your Firestore database grows, retrieving large amounts of data in a single query can become inefficient and costly. Pagination helps by breaking data retrieval into smaller, manageable chunks, improving performance and reducing Firestore read costs.

In this article, we’ll cover:

  • Why pagination is necessary
  • The different ways to paginate Firestore queries
  • Implementing cursor-based pagination for optimal performance

  • Why Pagination Matters

    Firestore charges per document read. Without pagination:

  • Queries that return large datasets lead to high read costs.
  • The app’s performance suffers as loading large datasets increases response times.
  • Users experience longer load times instead of seeing data in batches.
  • By implementing pagination, you:

    ✅ Load data in smaller, efficient chunks

    ✅ Reduce unnecessary reads

    ✅ Improve app responsiveness


    Firestore Pagination Techniques

    1. Offset-Based Pagination (Inefficient)

    Offset-based pagination uses .limit() and skips documents by reading all the previous ones before returning the next batch.

    Example: Loading users with an offset

    Language: javascript
    import { collection, query, orderBy, limit, getDocs } from "firebase/firestore";
    
    const db = getFirestore();
    const usersRef = collection(db, "users");
    const q = query(usersRef, orderBy("createdAt"), limit(10));
    const querySnapshot = await getDocs(q);

    🔴 Problem: When you retrieve the next page, Firestore still reads all previous documents, leading to excessive reads.


    2. Cursor-Based Pagination (Efficient)

    A better approach is using cursors (startAfter(), startAt(), endAt(), endBefore()) to fetch only what is needed.

    Step 1: Initial Query

    We order data by a field (e.g., createdAt timestamp) and set a limit.

    Language: javascript
    import { collection, query, orderBy, limit, getDocs } from "firebase/firestore";
    
    const db = getFirestore();
    const usersRef = collection(db, "users");
    const q = query(usersRef, orderBy("createdAt"), limit(10));
    const querySnapshot = await getDocs(q);
    
    const lastVisibleDoc = querySnapshot.docs[querySnapshot.docs.length - 1];

    🔹 Fetches the first 10 documents sorted by createdAt.

    🔹 Stores the last document to use as a cursor.


    Step 2: Fetch Next Page

    Use .startAfter(lastVisibleDoc) to load the next batch efficiently.

    Language: javascript
    const nextQuery = query(usersRef, orderBy("createdAt"), startAfter(lastVisibleDoc), limit(10));
    const nextQuerySnapshot = await getDocs(nextQuery);
    

    Only fetches new documents, avoiding unnecessary reads.


    Step 3: Fetch Previous Page (Optional)

    For backward navigation, store the first document and use .endBefore(firstVisibleDoc).

    Language: javascript
    const prevQuery = query(usersRef, orderBy("createdAt"), endBefore(firstVisibleDoc), limit(10));
    const prevQuerySnapshot = await getDocs(prevQuery);
    

    🔹 Fetches the previous batch efficiently.


    Best Practices for Firestore Pagination

    🔥 Always use indexes: Firestore requires an index for paginated queries. If an index is missing, Firestore provides a console link to create one.

    🔥 Use immutable fields: createdAt timestamps work best because they never change.

    🔥 Limit the number of results: Keep limits reasonable (e.g., 10–50) for smooth UX.

    🔥 Store cursors in state: Save the lastVisibleDoc for seamless navigation.


    Conclusion

    Pagination is essential for performance and cost efficiency in Firestore. Cursor-based pagination is the most scalable approach, ensuring:

    Lower read costs by fetching only needed documents

    Faster response times compared to offset-based pagination

    Better UX by smoothly loading data in batches

    By implementing these best practices, your Firestore app will remain fast and cost-effective. 🚀