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.

Advanced beginner105–125 minutesStable cursor pagination labFirebase JS 12.19.0 · CLI 15.30.0Last reviewed: September 2026

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.

01

Explain default document-ID ordering, explicit orderBy(), field-existence filtering, and multi-field tie-breaking.

02

Distinguish inclusive startAt/endAt from exclusive startAfter/endBefore boundaries.

03

Use document snapshots or complete ordered field tuples for cursors instead of ambiguous display values.

04

Use limit and limitToLast correctly, including the Web SDK requirement that limitToLast has an orderBy.

05

Design cursor tokens around immutable/stable ordering fields and state the consistency limits of pagination across changing data.

Execution and safety note

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.

Chapter 05 reproducibility baseline · reviewed 15 September 2026

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.

What was and was not executed while authoring

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.

Web modular SDK · stable two-key order
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));
Snapshot cursors are convenient, not snapshot isolation across pages.

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

shell · pinned local dependencies
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
seed-query-fixtures.mjs · deterministic AtlasMart query data
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");
pagination-contract.mjs · page without gaps across ties
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

  1. Why can orderBy("rank") remove a matching document?
  2. Why is startAfter(99) unsafe when many documents have price 99?
  3. What is the difference between startAt and startAfter?
  4. What does a document-snapshot cursor not guarantee?
  5. 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

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.