Chapter 05 · Queries: Filters, Ordering, Limits, Cursors, Collection Groups, and Query Constraints
orderBy, limit / limitToLast, startAt / startAfter / endAt / endBefore, and Stable Cursor Pagination
Build deterministic Firestore ordering and cursor pagination that remains correct across ties, missing fields, limits, and changing result sets.
Learning outcomes
AtlasMart can filter products correctly and still ship broken pagination. Two products have the same price, another lacks a ranking field, and new writes can arrive between page requests. This lesson makes ordering and cursor boundaries explicit so “next page” means the same thing to the SDK, the UI, and the test suite.
Explain default document-ID ordering, explicit
orderBy(), field-existence filtering, and
multi-field tie-breaking.
Distinguish inclusive startAt/endAt from
exclusive startAfter/endBefore boundaries.
Use document snapshots or complete ordered field tuples for cursors instead of ambiguous display values.
Use limit and
limitToLast correctly, including the Web SDK
requirement that limitToLast has an
orderBy.
Design cursor tokens around immutable/stable ordering fields and state the consistency limits of pagination across changing data.
Use the Emulator Suite, a Firebase demo project, or an isolated test project for destructive, security-sensitive, billing-sensitive, migration, backup/restore, or write-heavy exercises unless the lesson explicitly marks managed verification as required. Treat shown output as expected evidence unless it is explicitly identified as captured output, and re-check current Firebase/Google Cloud edition, mode, quota, pricing, and security documentation before production execution.
AtlasMart keeps the same local course environment used in
Chapters 01–04: project ID
demo-atlasmart-firestore, Standard edition /
Native mode / (default) database for
Core-operation labs, Firestore emulator
127.0.0.1:8080, Authentication emulator
127.0.0.1:9099, Emulator UI
127.0.0.1:4000, Firebase CLI
15.30.0, Firebase JavaScript SDK
12.19.0, Firebase Admin Node SDK
14.4.0 (which currently carries
@google-cloud/firestore 9.1.0),
@firebase/rules-unit-testing 5.0.2,
and Node.js 22+. No moving latest tag is required
by the lab.
The generation environment does not run the Firebase emulators, so code and commands are checked against current official Firebase/Google Cloud documentation but the output shown in this lesson is an expected invariant, not fabricated captured output. In particular, the Firestore emulator does not enforce production composite indexes in the same way as the managed service. Operator validation, deterministic result ordering, cursor contracts, and Security Rules tests belong in the mandatory local lab; a missing-composite-index error or Query Explain evidence must be treated as a separately labeled production verification.
1. Ordering changes both sequence and membership
Without an explicit order, a Core query returns matching
documents in ascending document-ID order. Once AtlasMart adds
orderBy("rank"), documents that do not contain
rank are excluded. That is not merely sorting; it
changes the set of documents eligible for the result. An
inequality filter has a related implied ordering/existence
effect on the inequality field.
| Fixture | rank value | orderBy("rank")? |
|---|---|---|
p-1001 |
10 | Included |
p-1002 |
20 | Included |
p-1003 |
missing | Excluded |
p-1004 |
null | Included; null exists and participates in Firestore value ordering |
p-1005 |
30 | Included |
If “unranked” products must appear, model that state intentionally—for example with a sentinel/status field or a separate query/view—instead of assuming missing fields sort to the end.
2. Tie values need a deterministic final key
AtlasMart has three products priced at 99. A scalar cursor
startAfter(99) cannot identify which 99-priced
product was the final row of the page. The safer contract is
either a document snapshot cursor or an explicit total order
such as price plus document ID. Firestore indexes
also apply a final ordering by document name/path, but writing
the tie-breaker explicitly makes the API contract visible and
portable in tests.
import { collection, documentId, getDocs, limit, orderBy, query, startAfter } from "firebase/firestore";const items = collection(db, "catalogItems");const first = query(items, orderBy("price","asc"), orderBy(documentId(),"asc"), limit(2));const page1 = await getDocs(first);const last = page1.docs.at(-1);const second = query(items, orderBy("price","asc"), orderBy(documentId(),"asc"), startAfter(last), limit(2));const page2 = await getDocs(second);console.log(page1.docs.map(d=>d.id), page2.docs.map(d=>d.id));
The last-document snapshot contributes cursor field values. It does not freeze the collection while the user paginates. Inserts, deletes, or ordering-field changes between page requests can move rows across boundaries. For audit-grade traversal, use a design with immutable ordering keys, a cutoff timestamp/version, or a server-side export/job rather than promising a stable historical snapshot from ordinary UI pagination.
3. Cursor boundary vocabulary
| Constraint | Boundary meaning | Typical use |
|---|---|---|
startAt(x) |
Include x and later values | Resume including a known anchor |
startAfter(x) |
Exclude x; begin after it | Next-page cursor |
endAt(x) |
Include x and earlier values | Inclusive upper range |
endBefore(x) |
Exclude x; stop before it | Exclusive upper range |
limit(n) |
First n in query order | Forward page |
limitToLast(n) |
Last n in query order | Reverse/backward window; Web SDK requires an orderBy |
When a cursor uses raw field values, the values must match the
orderBy clauses in order. If one field is not
unique, add enough fields to identify the intended boundary. A
document snapshot is often safer because the SDK extracts the
query's ordered values from that document.
4. Deliberately wrong approach: use product name or price as the page token
A UI engineer encodes ?afterPrice=99. Page 1 ends
on p-1002, but p-1006 also costs 99.
startAfter(99) can skip remaining ties. The repair
is a complete ordered tuple—price + documentId—or a
document-snapshot-derived cursor when the SDK session retains
it. If pagination tokens cross process boundaries, serialize the
primitive ordered fields and a stable ID, validate them, and
version the token schema.
5. Hands-on lab: pagination under ties and missing fields
mkdir atlasmart-firestore-query-labcd atlasmart-firestore-query-labnpm init -ynpm install firebase@12.19.0 firebase-admin@14.4.0npm install --save-dev firebase-tools@15.30.0 @firebase/rules-unit-testing@5.0.2node --versionnpx firebase --versionnpx firebase emulators:start --only firestore,auth --project demo-atlasmart-firestore
process.env.FIRESTORE_EMULATOR_HOST = "127.0.0.1:8080";import { initializeApp, applicationDefault } from "firebase-admin/app";import { getFirestore, Timestamp } from "firebase-admin/firestore";initializeApp({ projectId: "demo-atlasmart-firestore" });const db = getFirestore();const catalog = { "p-1001": { category:"camera", price:99, rating:4.8, tags:["outdoor","camera"], published:true, rank:10, sellerId:"seller-a" }, "p-1002": { category:"camera", price:99, rating:4.5, tags:["studio","camera"], published:true, rank:20, sellerId:"seller-a" }, "p-1003": { category:"sensor", price:49, rating:4.8, tags:["outdoor","iot"], published:true, sellerId:"seller-b" }, "p-1004": { category:"sensor", price:149, rating:4.1, tags:["industrial","iot"], published:false, rank:null, sellerId:"seller-b" }, "p-1005": { category:"camera", price:199, rating:4.9, tags:["outdoor","camera"], published:true, rank:30, sellerId:"seller-c" }, "p-1006": { category:"gateway", price:99, rating:4.2, tags:["iot"], published:true, rank:40, sellerId:"seller-c" }};for (const [id, data] of Object.entries(catalog)) { await db.collection("catalogItems").doc(id).set({ ...data, updatedAt: Timestamp.fromMillis(1760000000000) });}const orders = [ ["u-alice","o-1001",{ ownerUid:"u-alice", tenantId:"seller-a", status:"paid", total:198, createdAt:Timestamp.fromMillis(1760000100000) }], ["u-alice","o-1002",{ ownerUid:"u-alice", tenantId:"seller-b", status:"shipped", total:49, createdAt:Timestamp.fromMillis(1760000200000) }], ["u-bob", "o-1003",{ ownerUid:"u-bob", tenantId:"seller-a", status:"paid", total:99, createdAt:Timestamp.fromMillis(1760000300000) }]];for (const [uid,id,data] of orders) await db.doc(`users/${uid}/orders/${id}`).set(data);console.log("seeded", Object.keys(catalog).length, "catalog items and", orders.length, "orders");
import assert from "node:assert/strict";import { initializeApp } from "firebase/app";import { collection, connectFirestoreEmulator, documentId, getDocs, getFirestore, limit, orderBy, query, startAfter } from "firebase/firestore";const app=initializeApp({projectId:"demo-atlasmart-firestore",apiKey:"demo",appId:"demo"});const db=getFirestore(app); connectFirestoreEmulator(db,"127.0.0.1",8080);const c=collection(db,"catalogItems");const base=[orderBy("price","asc"),orderBy(documentId(),"asc")];const p1=await getDocs(query(c,...base,limit(3)));const p2=await getDocs(query(c,...base,startAfter(p1.docs.at(-1)),limit(3)));const all=[...p1.docs,...p2.docs].map(d=>d.id);assert.equal(new Set(all).size, all.length);assert.deepEqual(all,["p-1003","p-1001","p-1002","p-1006","p-1004","p-1005"]);console.log("stable two-page traversal",all);
Next, run orderBy("rank") and prove
p-1003 disappears because the field is missing. Add
an explicit rank/sentinel in a reset fixture and prove it
becomes queryable. Finally, insert a new 99-priced product
between page requests and record whether the UI's chosen
pagination contract tolerates the changed membership.
Production judgment
Choose ordering fields because they represent product semantics, not because a UI component happened to need a sort. Stable pagination is easiest when ordered fields are immutable, fully populated, indexed, authorized, and uniquely tie-broken. If the result must be a historical snapshot, ordinary cursors are the wrong abstraction.
Knowledge check
-
Why can
orderBy("rank")remove a matching document? -
Why is
startAfter(99)unsafe when many documents have price 99? -
What is the difference between
startAtandstartAfter? - What does a document-snapshot cursor not guarantee?
- When should AtlasMart prefer an immutable composite cursor key?
Review the answers
1. Because ordering by a field also requires that the field exist; missing-field documents are outside the result set.
2. The scalar boundary cannot identify which tied document ended the previous page; remaining ties can be skipped or ambiguously partitioned.
3. The former includes the boundary; the latter excludes it.
4. It does not freeze the collection across page requests; concurrent mutations can change later membership/order.
5. When pagination spans requests/processes and must be reproducible, debuggable, and robust to ties.
Summary and next step
AtlasMart now has deterministic local pagination. Lesson 3 expands the query scope from one collection to every same-named subcollection using collection-group queries—and makes the corresponding index and Security Rules scope explicit.
Authoritative references
- Perform simple and compound queries in Cloud Firestore — Current Core filter/operator behavior and Standard-edition limitations.
- Order and limit data with Cloud Firestore — Ordering, limits, field-existence effect, and limit semantics.
- Paginate data with query cursors — Cursor boundaries and document-snapshot pagination.
- Index types in Cloud Firestore — Index scopes, implicit document-name ordering, and query/index execution model.
- Securely query data — Why Security Rules are not filters and collection-group rule requirements.
- Firebase release notes — Current SDK/tooling versions used for the pinned lab baseline.