Firestore Pagination That Doesn't Lie to You: Cursors, Tiebreakers, and Read Costs
Firestore's cursor pagination breaks in predictable ways: duplicate sort values, mid-scroll writes, and per-read billing. Here's how to build 'load more' that stays stable.
15 Jun 2026, 15:15 UTC

You shipped a feed with a \"Load more\" button. It works in testing, then a user reports seeing the same post twice — and another post vanishing entirely. The bug usually isn't your UI code. It's that Firestore pagination is cursor-based, not offset-based, and it only stays stable if you order deterministically and understand what a cursor actually points at.
The takeaway: always pair orderBy with a unique tiebreaker, keep the last DocumentSnapshot as your cursor, and treat every fetched document as a billed read. Get those three right and \"load more\" is boring and reliable.
Why Firestore has no \"skip 200\"
SQL's OFFSET 200 LIMIT 20 feels natural, but Firestore doesn't offer efficient server-side offset skipping. Instead, you paginate with cursors: startAfter() (or endBefore()) combined with limit(). The cursor marks a position in the sorted result set, and the next query resumes from there.
You can pass either a DocumentSnapshot or raw field values to startAfter(). The snapshot form is easiest while the user is clicking through pages in one session; raw values are what you persist if a cursor must survive a reload, because a snapshot object can't be serialized to localStorage.
The duplicate-value trap
Every paginated query needs an orderBy, and here's the part that bites: if the sort field has duplicate values, the cursor position is ambiguous. Sort a feed by likeCount and you'll eventually have fifty posts with zero likes. A cursor that says \"start after likeCount = 0\" doesn't know which zero-like post you mean, so pages can overlap or skip documents.
The fix is a deterministic tiebreaker — order by the sort field first, then by something unique, conventionally the document ID:
// Firebase JS SDK v9+ (modular). v8 uses firebase.firestore() chaining instead.\nimport {\n collection, query, orderBy, limit, startAfter, getDocs\n} from \"firebase/firestore\";\n\nconst PAGE_SIZE = 20;\nlet lastVisible = null; // DocumentSnapshot from the previous page\n\nasync function loadNextPage(db) {\n const constraints = [\n orderBy(\"createdAt\", \"desc\"),\n orderBy(\"__name__\", \"desc\"), // document ID tiebreaker\n limit(PAGE_SIZE),\n ];\n if (lastVisible) constraints.push(startAfter(lastVisible));\n\n const q = query(collection(db, \"posts\"), ...constraints);\n const snap = await getDocs(q);\n lastVisible = snap.docs[snap.docs.length - 1] ?? lastVisible;\n return snap.docs.map((d) => ({ id: d.id, ...d.data() }));\n}Run this in your client web app wherever you already call Firestore — no special permissions beyond your normal read rules. __name__ refers to the document ID in the modular SDK. A practical check: seed a collection where many documents share the same createdAt, paginate through everything, and assert the collected IDs contain no duplicates and no gaps. Without the tiebreaker, you'll see the failure; with it, you won't.
Cursors drift under writes — decide if you care
A cursor is a position, not a snapshot of the world. If someone publishes a new post that sorts into page 1 while your user is loading page 2, everything shifts down by one and the last item of page 1 reappears at the top of page 2. For a social feed, a rare duplicate is fine. For a strict export or an audit-style listing, it isn't — you'd need a different approach, such as exporting from a fixed timestamp filter (where(\"createdAt\", \"<\", capturedAt)) so new writes land outside the query window.
One more constraint worth knowing before you design the query: with inequality filters (where(\"price\", \">\", 100)), your first orderBy must be on the inequality field. Plan the sort order around that, then append the tiebreaker.
Every page is a bill
Firestore charges per document read, and pagination doesn't change that — each fetched document is one billed read. This drives two real decisions:
- Page size. Small pages cost less per click but add round trips and latency. Twenty items is a common sweet spot for feeds; five is usually churn, a hundred is usually waste.
- Prefetching and caching. Fetching the next page while the user reads the current one makes the UI feel instant but pays for reads the user may never scroll to. Caching visited pages client-side avoids re-reading on \"back\" navigation at the cost of memory and staleness.
Numbered pagination (\"jump to page 7\") deserves a special warning: Firestore can't seek to a position without walking there, so emulating it means fetching and discarding pages 1–6 — and paying for every one of those reads. At scale it's an anti-pattern; prefer \"load more\" or keyset-style navigation.
To verify cost behavior yourself, run a pagination test against a known collection and check the usage tab in the Firebase console: reads should match documents actually fetched, not documents you conceptually \"skipped.\" Pricing and free-tier quotas change, so confirm current terms before making capacity decisions.
A short checklist before you ship
- Deterministic order: sort field plus a unique tiebreaker (
__name__). - Cursor stored as the last
DocumentSnapshotper session; persist raw orderBy values only if cursors must survive reloads. - A stated answer to \"what happens when a write lands mid-pagination?\" — tolerate duplicates, or bound the query with a timestamp.
- Page size chosen as a cost/latency trade-off, not a default.
- No offset-style page jumping anywhere in the design.
Do those five things and pagination stops being the feature users file bugs about.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.