Pagination with Firestore Real‑Time Listeners and Query Cursors
Combine Firestore onSnapshot with query cursors for live pagination of large collections without re‑fetching the whole set.
21 Jun 2026, 15:10 UTC

Problem: UI freezes when listing thousands of Firestore documents
When a Firestore collection grows beyond a few hundred documents, a single get() call that returns every record can block the main thread, inflate network usage, and leave the UI unresponsive. Users expect smooth scrolling or lazy loading, but pulling an entire collection at once is impractical.
Thesis: Real‑time listeners paired with query cursors give you live pagination
Firestore’s onSnapshot API maintains a lightweight connection that pushes document changes to the client as they happen. By combining it with the startAfter (or endBefore) cursor methods, you can page through massive collections document‑by‑document or page‑by‑page while the UI stays in sync.
How listeners and cursors work together
Cursors require a consistent ordering field, typically an indexed timestamp or numeric ID. The orderBy clause sorts documents, and startAfter(lastDoc) tells Firestore to return only documents that come after the specified snapshot. Because the listener is real‑time, any document that matches the new cursor appears automatically, without an extra round‑trip.
Worked example: paging through a collection with onSnapshot
import { initializeApp } from 'firebase/app'; import { getFirestore, collection, query, orderBy, startAfter, onSnapshot } from 'firebase/firestore'; const firebaseConfig = { apiKey: 'YOUR_API_KEY', authDomain: 'YOUR_PROJECT_ID.firebaseapp.com', projectId: 'YOUR_PROJECT_ID' }; const app = initializeApp(firebaseConfig); const db = getFirestore(app); const collRef = collection(db, 'YOUR_COLLECTION_NAME'); const baseQ = query(collRef, orderBy('createdAt')); let cursor = null; const unsubscribe = onSnapshot(baseQ, (snapshot) => { snapshot.docChanges().forEach((change) => { if (change.type === 'added') { console.log('New doc ID:', change.doc.id); } }); if (snapshot.docs.length) { cursor = snapshot.docs[snapshot.docs.length - 1]; } }); function loadNextPage() { if (!cursor) { console.log('No more pages – you’re reached the end'); return; } const pageQ = query(baseQ, startAfter(cursor)); unsubscribe(); const nextUnsubscribe = onSnapshot(pageQ, (pageSnapshot) => { pageSnapshot.docChanges().forEach((change) => { if (change.type === 'added') { console.log('Page doc added:', change.doc.id); } }); if (pageSnapshot.docs.length) { cursor = pageSnapshot.docs[pageSnapshot.docs.length - 1]; } ); unsubscribe = nextUnsubscribe; }Where to run: This snippet works in a browser bundled with the Firebase JavaScript SDK (v9+) or in a Node.js script using the Admin SDK with a service account that has firestore.doc.read permission. The client must have read access on the target collection.
Expected checks: After the initial onSnapshot fires, open the console and verify that the first batch of document IDs appears. Call loadNextPage() and confirm that the next batch of IDs begins after the last shown ID, with no gaps or repeats. If the cursor points to a document that was just deleted or modified, Firestore may return an empty page or throw a UNKNOWN status – this is expected behavior when the dataset changes concurrently.
Trade‑offs and limitations
Client‑side state: You must persist the cursor (or its serialized form) between page loads. Forgetting to update it causes duplicate or skipped pages.Out‑of‑order updates: If a document matching the cursor is deleted or its ordering field changes while you’re paging, the cursor may become invalid. The SDK reports this as aUNKNOWNstatus rather than silently skipping.SDK version: ThestartAftermethod is guaranteed in Firebase SDK 9.0+. Earlier releases (8.x) may not expose it or may behave inconsistently.Connection limits: Each activeonSnapshotlistener consumes a persistent websocket‑style connection. Browsers typically cap concurrent streams at 6‑10; keeping many listeners open can cause throttling or disconnections.
Verification and closing
To confirm the pattern works for your data, create a test collection with at least 100 documents, each containing a unique, monotonically increasing createdAt field. Run the initial listener, record the first 10 IDs, invoke loadNextPage() repeatedly, and tally the total unique IDs collected. The count should match the expected total with no duplicates. Check the Firebase console or npm list firebase to ensure your SDK version is 9.0 or newer. When you’re satisfied, replace the test data with your production collection, persist the cursor in your app’s state (e.g., localStorage or Redux), and monitor the Network tab to confirm that only one initial connection remains open while subsequent pages fetch minimally.
Pairing Firestore real‑time listeners with query cursors gives you efficient, live pagination without the bandwidth cost of repeated full‑collection fetches. Start with a single onSnapshot, store the last document as your cursor, and use startAfter for the next page. Keep the SDK up to date, respect client‑side connection limits, and test with a modest dataset before scaling to production.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.