Skip to content

Firestore Pagination with Cursors — Efficient Large Dataset Navigation

DodaTech Updated 2026-06-28 4 min read

In this tutorial, you will learn about Firestore Pagination with Cursors. We cover key concepts, practical examples, and best practices to help you master this topic.

Firestore pagination uses cursor-based navigation with the limit, startAfter, startAt, endBefore, and endAt methods, enabling efficient traversal through large collections without the read cost of offset-based pagination.

What You'll Learn

  • Cursor-based pagination vs offset pagination
  • Forward and backward pagination with cursors
  • Combining pagination with filters and sorting

Why It Matters

Offset-based pagination reads all skipped documents, wasting reads. Cursor-based pagination reads only the requested page. DodaTech's audit log viewer uses cursor pagination for efficient browsing of millions of log entries.

flowchart LR
    A["Page 1"] --> B["Get 20 documents"]
    B --> C["Save last document as cursor"]
    C --> D["Page 2: startAfter(cursor)"]
    D --> E["Get next 20 documents"]
    E --> F["Update cursor"]
    F --> G["Page 3: startAfter(new cursor)"]

Code Examples

// Forward pagination
import { collection, query, orderBy, limit, startAfter, getDocs } from 'firebase/firestore';

let lastVisible = null;
const PAGE_SIZE = 20;

async function loadNextPage() {
  let q;

  if (lastVisible) {
    // Start after the last document from previous page
    q = query(
      collection(db, 'users'),
      orderBy('createdAt', 'desc'),
      startAfter(lastVisible),
      limit(PAGE_SIZE)
    );
  } else {
    // First page
    q = query(
      collection(db, 'users'),
      orderBy('createdAt', 'desc'),
      limit(PAGE_SIZE)
    );
  }

  const snapshot = await getDocs(q);
  const documents = snapshot.docs.map(doc => ({
    id: doc.id,
    ...doc.data()
  }));

  // Update cursor to last document
  if (snapshot.docs.length > 0) {
    lastVisible = snapshot.docs[snapshot.docs.length - 1];
  }

  return {
    documents,
    hasMore: snapshot.docs.length === PAGE_SIZE,
    firstDoc: snapshot.docs[0] || null,
    lastDoc: lastVisible
  };
}
// Full bidirectional pagination
class FirestorePaginator {
  constructor(collectionName, orderField, pageSize = 20) {
    this.collectionRef = collection(db, collectionName);
    this.orderField = orderField;
    this.pageSize = pageSize;
    this.cursors = [];
    this.currentPage = -1;
  }

  async goToPage(pageIndex) {
    if (pageIndex < 0 || pageIndex > this.cursors.length) {
      return null;
    }

    let q;
    if (pageIndex === 0) {
      q = query(
        this.collectionRef,
        orderBy(this.orderField, 'desc'),
        limit(this.pageSize)
      );
    } else {
      const cursor = this.cursors[pageIndex - 1];
      q = query(
        this.collectionRef,
        orderBy(this.orderField, 'desc'),
        startAfter(cursor),
        limit(this.pageSize)
      );
    }

    const snapshot = await getDocs(q);
    const docs = snapshot.docs.map(d => ({
      id: d.id,
      ...d.data()
    }));

    // Store cursor for next page
    if (snapshot.docs.length > 0) {
      this.cursors[pageIndex] = snapshot.docs[snapshot.docs.length - 1];
    }

    this.currentPage = pageIndex;
    return {
      documents: docs,
      hasMore: snapshot.docs.length === this.pageSize,
      page: pageIndex,
      totalPages: this.cursors.length
    };
  }
}
# Python Firestore cursor pagination
from google.cloud import firestore

db = firestore.Client()

class CursorPaginator:
    def __init__(self, collection, page_size=20):
        self.collection = db.collection(collection)
        self.page_size = page_size
        self.last_doc = None

    def get_page(self, last_doc_id=None):
        query = (
            self.collection
            .order_by('createdAt', direction=firestore.Query.DESCENDING)
            .limit(self.page_size)
        )

        if last_doc_id:
            last_doc = self.collection.document(last_doc_id).get()
            if last_doc.exists:
                query = query.start_after(last_doc)

        docs = list(query.stream())
        return {
            'documents': [d.to_dict() for d in docs],
            'has_more': len(docs) == self.page_size,
            'last_id': docs[-1].id if docs else None
        }

Common Mistakes

1. Using Offset Instead of Cursors

Offset reads all skipped documents. Use startAfter/startAt for efficient pagination.

2. Forgetting OrderBy for Cursor Pagination

Cursors require an orderBy clause. Without orderBy, cursor behavior is undefined.

3. Not Persisting Cursors Between Page Loads

Store the cursor (last visible document reference) in state or URL parameters.

4. Using startAt Instead of startAfter

startAt includes the cursor document. startAfter skips it, starting from the next document.

5. Ignoring Edge Cases

Handle empty pages, no more pages, and single-page results correctly.

Practice Questions

  1. Why is cursor-based pagination better than offset?
  2. What Firestore methods enable cursor-based pagination?
  3. What is required for cursor pagination besides a cursor?
  4. How do you paginate backward?
  5. Can you use cursors with filtered queries?

Answers:

  1. Cursor pagination does not read skipped documents, saving reads.
  2. startAfter, startAt, endBefore, endAt.
  3. An orderBy clause on the same field(s) as the cursor.
  4. Use endBefore with the first document of the current page as the cursor.
  5. Yes. Combine cursors with where filters and the required Composite indexes.

Challenge: Build a paginated product catalog with Firestore. Implement forward and backward pagination, combine with category filters and price range filters, and persist page state in URL parameters for bookmarkable pages.

FAQ

Can I use pagination with real-time listeners?

Yes. Use onSnapshot with the same query structure. The listener returns the current page and updates in real time.

How do I get the total document count for paginated results?

Firestore does not provide exact counts for paginated queries. Use a separate counter document or approximate counts from indexed metadata.

What happens when documents are added between page loads?

Cursors point to specific documents, so new documents before the cursor are included in subsequent queries. This may cause documents to appear in multiple pages.

Can I use multiple cursors for multi-field sorting?

Yes. Use multiple values in startAfter for multi-field orderBy. Pass the same fields in the same order as the orderBy.

How do I reset pagination when filters change?

Reset the cursor to null and re-run the query with the new filters. The cursor is meaningless when filters change because the result set is different.

Mini Project

Build a Firestore pagination library that supports forward and backward navigation, configurable page sizes, combined with filtering and sorting, cursor persistence in URL state, and Infinite Scroll integration. Include performance benchmarks comparing cursor vs offset pagination.

What's Next

Learn about Firebase Authentication with email and password login, then explore OAuth providers for social login.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro