Firestore Infinite Scroll: Why Cursor Pagination Beats Offset Paging
Offset paging looks fine in a Firestore demo, but it bills for skipped documents and breaks under data changes. Cursor queries with startAfter keep infinite-scroll feeds proportional to page size—if you choose a stable sort key.
02 Jul 2026, 19:45 UTC

An infinite-scroll feed built with limit() and offset() can pass a demo and then quietly drain a Firestore budget. The problem is not that offset is slow; it is that Firestore is a billed, indexed document store. Every page fetched with offset asks the backend to examine documents the client never sees. For a feed, that waste compounds with every scroll.
The fix is cursor-based pagination. A stable orderBy, a limit, and startAfter keep each page proportional to the page size. The cost of page ten is the same as page one.
Why offset fails in a billed document store
offset(N) tells Firestore to skip N documents. In a small demo with 20 items, offset(15) plus limit(5) looks instant. In production with thousands of comments, the backend still reads and discards the skipped documents before returning your page. Because Firestore bills per document read, those skipped documents are not free. Worse, offset is not stable: if a document is inserted or deleted before the skipped range, the window shifts and users see duplicates or gaps.
The cursor pattern
A cursor is the last document the client actually saw. To build one, pick an immutable sort key such as a server-set createdAt timestamp. Add a tie-breaker on the document ID because timestamps can collide, especially with batched writes. After each page, store the last returned values: the createdAt value and the document ID.
The next query repeats the same orderBy clauses, then calls startAfter(lastCreatedAt, lastId), then limit(pageSize). This assumes forward-only paging. If users need to jump to page 50 or scroll backward arbitrarily, cursor pagination is not the right tool.
Worked example: a hypothetical comment feed
The following handler is illustrative. It assumes a modular Firebase SDK (v9+). Verify the exact import style and method signatures for your installed firebase version before deploying, because compat and modular APIs differ.
// Conceptual pattern. Verify against your installed firebase package.
const PAGE_SIZE = 10;
let cursor = null; // { createdAt, id }
let loading = false;
async function loadMore() {
if (loading) return;
loading = true;
const btn = document.getElementById("load-more");
btn.disabled = true;
try {
const baseQuery = query(
collection(db, "comments"),
orderBy("createdAt", "desc"),
orderBy("__name__", "desc"),
limit(PAGE_SIZE)
);
const pageQuery = cursor
? query(baseQuery, startAfter(cursor.createdAt, cursor.id))
: baseQuery;
const snapshot = await getDocs(pageQuery);
const docs = snapshot.docs;
// Append docs to the feed here...
if (docs.length < PAGE_SIZE) {
btn.remove(); // End of feed
} else {
const last = docs[docs.length - 1];
cursor = {
createdAt: last.get("createdAt"),
id: last.id
};
}
} finally {
loading = false;
if (btn) btn.disabled = false;
}
}
Notice the guard: if a page returns fewer documents than requested, the handler treats it as the end of the feed and stops issuing queries. The button is disabled while a request is running to prevent overlapping fetches.
Trade-offs and operational reality
Cursor pagination is not stable under a mutable ordering field. If createdAt is updated, or if documents are inserted with timestamps inside the already-viewed range, users can see duplicates or miss items. For feeds, an immutable timestamp plus ID tie-breaker is usually correct. For ranking-style feeds where scores change, a different model may be needed.
Multi-field ordering needs a composite index. Ordering by createdAt and then by __name__ will require one. Create it deliberately in the Firebase console or your firestore.indexes.cfg rather than relying only on a development error link. A snippet that compiles locally can still fail in production without the required index.
Security rules apply to every page. A rule that works for the first page may reject page two if it depends on resource data or request time. Test rules against later pages, not just the initial load.
Actionable checklist
- Pick an immutable sort key plus a document ID tie-breaker.
- Replace
offsetwithlimitandstartAfter. - Store cursors in short-lived client state or server-side session state; do not persist them long-term.
- Treat a short or empty page as the end of the feed.
- Add the composite index required by your
orderByclauses. - Verify with a seeded dataset larger than one page: seed at least 50 documents with non-unique sort values, page through with
limit(5), and assert the union equals the seeded set with no duplicates or gaps. - Compare
offset(45)pluslimit(5)againststartAfter(cursor)pluslimit(5)in the emulator or usage console to confirm the cursor page does not rely on skipped documents for correctness. - Simulate an insert or update between page fetches and record whether the feed duplicates or skips the changed document.
Offset paging is not a stylistic choice in Firestore. It is a billing and correctness decision. Cursor queries make the cost of scrolling proportional to what the user actually sees.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.