Stop Using Offset for Firestore Lists: Use Cursors
Offset paging in Firestore scales poorly. Use limit with startAfter and a stable orderBy to paginate by cursor, keep client state minimal, and accept the consistency trade-offs of anchored pages.
02 Jun 2026, 18:02 UTC

Offset paging looks simple until the list grows
Building a feed or admin table with Firestore often starts with limit and offset. Offset forces the server to read and discard documents before the page you need. The cost and latency grow with the page number, not with the page size. For a client-side list UI that needs predictable performance, cursor pagination is the practical alternative.
How Firestore cursors bound a page
Firestore provides startAt, startAfter, endAt and endBefore. These accept a document snapshot or the field values that define the position in an ordered query. The server can resume from that position and return only the next N documents.
Cursor pagination requires an explicit orderBy. Unordered queries cannot be paginated reliably. If the query also filters, a composite index is usually required. You can check the required index in the error message returned by getDocs, or pre-create it in the Firebase console under Firestore Indexes.
The cursor is a reference to a specific document in the ordering. If documents are inserted or deleted between pages, the next page is still anchored to that document. That stability is useful, but it also means the result set can shift: new items that land before the cursor are not seen on the next page, and deletions can create gaps. Using a stable, monotonic ordering field such as a timestamp reduces surprise, but does not eliminate it.
Reusable page fetch with startAfter
The pattern is limit plus an optional startAfter. Keep the last visible document from the previous page and pass it as the cursor for the next request.
import { collection, query, orderBy, limit, startAfter, getDocs } from "firebase/firestore";
import { db } from "./firebaseConfig";
export async function fetchPage(collPath, orderField, pageSize, cursor = null) {
const collRef = collection(db, collPath);
let q = query(collRef, orderBy(orderField, "desc"), limit(pageSize));
if (cursor) {
q = query(q, startAfter(cursor));
}
const snapshot = await getDocs(q);
const docs = snapshot.docs.map(d => ({ id: d.id, data: d.data() }));
const nextCursor = snapshot.docs.length === pageSize ? snapshot.docs[snapshot.docs.length - 1] : null;
return { docs, nextCursor };
}
Run this in a browser or Node environment where Firebase App is initialized and the caller has read permission on collPath. The function returns the documents for the page and a nextCursor that can be stored client-side. Only the document reference is needed for the next call; keeping the full snapshot is unnecessary and increases memory use.
Client usage typically stores the cursor for forward navigation and an array of previous cursors for back navigation. Example state shape:
const [items, setItems] = useState([]);
const [lastDoc, setLastDoc] = useState(null);
async function loadNext() {
const { docs, nextCursor } = await fetchPage("posts", "createdAt", 20, lastDoc);
setItems(prev => [...prev, ...docs]);
setLastDoc(nextCursor);
}
Risks to note: the query must remain identical between pages, including the same orderBy direction and filters. Changing the ordering invalidates the cursor. If the user jumps far back, you may need to refetch or maintain a small cache of page cursors.
Trade-offs and practical checks
Cursor pagination gives cost proportional to page size rather than page offset, and it works with Firestore’s query model. The trade-offs are client state and consistency.
- State: the client must hold a cursor per page direction. For very deep lists this adds memory pressure. Discard cursors you no longer need.
- Stale position: because the cursor points to a document, inserts before the cursor are not reflected on the next page. If you need live updates on the first page, use a separate onSnapshot listener for that page and keep cursor paging for deeper pages.
- Index requirements: queries with where + orderBy require a composite index. The first request will fail with a helpful link to create the index if missing.
- Deduplication: if you re-run a page after data changes, the same document can appear in two consecutive fetches. Client-side deduplication by document id is a common safeguard.
A practical way to check behavior is to create a test collection with a known ordering field, fetch two pages with limit and startAfter, and confirm the second page starts immediately after the first page’s last document. Then add a document that would sort before the first page and observe that the existing cursor still resumes from the same document. This demonstrates the anchoring behavior without assuming no gaps or duplicates.
Actionable takeaway
Replace offset-based paging with limit + startAfter anchored on a stable orderBy field. Keep the last visible document as the cursor, require the same query shape across pages, and plan for cursor staleness when data changes. This gives predictable reads per page and a UI that remains responsive as the collection grows.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.