Chapter 07 · Realtime Listeners: Snapshots, Change Events, Metadata, Latency, and Listener Lifecycle
Document vs Query Listeners, Initial Snapshot, Incremental Changes, Ordering, and Local Writes
Understand Firestore document/query listeners, initial snapshots, incremental changes, ordering, optimistic local writes, and unsubscribe ownership.
Learning outcomes
Distinguish a document listener from a query listener and treat each as a long-lived read contract rather than a push-notification subscription.
Explain why the initial query snapshot contains
added changes for the existing result set, then
interpret later added, modified,
and removed transitions.
Observe latency-compensated local writes with
hasPendingWrites and preserve query ordering as
documents enter, move within, or leave a result set.
Instrument listener ownership and unsubscribe paths so navigation does not silently multiply live reads.
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 continues the same environment used in Chapters
01–06: project ID demo-atlasmart-firestore,
Standard edition / Native mode /
(default) database for the main lab, 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 (bundling
@google-cloud/firestore 9.1.0), and Node.js 22+.
Browser examples import the pinned Web SDK from Google-hosted
modules and connect only to local emulators.
The authoring environment did not execute a billed Firestore database or collect production listener telemetry. Emulator steps below are deterministic correctness exercises; shown event sequences are expected invariants, not captured benchmark evidence. The emulator cannot prove production billing, WAN reconnect behavior, managed index enforcement, production latency percentiles, or all mobile background/OS lifecycle behavior.
1. AtlasMart problem: “live” inventory without pretending events are messages
AtlasMart wants a seller dashboard that always shows the ten lowest-stock items and a customer order screen that updates as status changes. A common mistake is to model Firestore listeners as an event bus: “subscribe once, then each callback is one exactly-once business event.” That is not the contract. A listener is a long-lived execution of a document or query read. It first establishes state, then keeps that state synchronized as Firestore observes changes.
A document listener watches one document
reference. A query listener watches the result
of a query. A snapshot is a point-in-time
client view of the document or query result;
docChanges() is a change-oriented representation of
how the query snapshot differs from the previous snapshot.
Latency compensation means local writes can
update listeners before the backend acknowledges them.
| Construct | Observable contract | Do not infer |
|---|---|---|
| Document listener | Current contents/existence of one document, then later changes | A durable business-event log |
| Query listener | Current ordered result set, then changes required to keep it current | Only documents changed on the server since app launch |
First docChanges() |
Existing matching documents appear as added
|
Those documents were newly created |
| Local write callback | May appear immediately with pending-write metadata | Server commit already succeeded |
| Listener error | Stream stops after terminal listen error | Automatic retry forever after an authorization/index error |
2. Initial snapshot and incremental ordering
For a query listener, the first snapshot is special only
operationally, not semantically: Firestore describes the changes
needed to transform an empty client result set into the current
result set, so every existing matching document is reported as
added. Later changes can be added,
modified, or removed. A
removed change can mean the document was deleted or
that an update caused it to stop matching the query.
Order matters. If the query is
orderBy("stock", "asc"), updating stock can produce
a modified change whose old/new indexes indicate
movement. Render from the snapshot's ordered
docs array or apply
docChanges() carefully; do not append every change
to the bottom of the UI.
<!doctype html><meta charset="utf-8"><title>AtlasMart listener lab</title><pre id="log"></pre><script type="module">import { initializeApp } from "https://www.gstatic.com/firebasejs/12.19.0/firebase-app.js";import { getAuth, connectAuthEmulator, signInAnonymously } from "https://www.gstatic.com/firebasejs/12.19.0/firebase-auth.js";import { getFirestore, connectFirestoreEmulator, collection, doc, query, where, orderBy, limit, onSnapshot, setDoc, updateDoc, serverTimestamp, disableNetwork, enableNetwork} from "https://www.gstatic.com/firebasejs/12.19.0/firebase-firestore.js";const app = initializeApp({ projectId:"demo-atlasmart-firestore", apiKey:"demo-key", appId:"demo-app" });const db = getFirestore(app); connectFirestoreEmulator(db, "127.0.0.1", 8080);const auth = getAuth(app); connectAuthEmulator(auth, "http://127.0.0.1:9099", { disableWarnings:true });const credential = await signInAnonymously(auth);const uid = credential.user.uid;console.log("uid", uid);const log = (...x) => document.querySelector('#log').textContent += x.join(' ') + "\n";let active = 0;function tracked(label, ref) { active++; log('ATTACH', label, 'active=', active); const stop = onSnapshot(ref, { includeMetadataChanges:true }, snap => { const rows = snap.docs ? snap.docs.map(d => `${d.id}:${d.data().stock}`) : [snap.id, snap.exists()]; const changes = snap.docChanges ? snap.docChanges({ includeMetadataChanges:true }).map(c => `${c.type}:${c.doc.id}:${c.oldIndex}->${c.newIndex}`) : []; log('SNAP', label, 'cache=',snap.metadata.fromCache,'pending=',snap.metadata.hasPendingWrites,'rows=',JSON.stringify(rows),'changes=',JSON.stringify(changes)); }, err => log('ERROR', label, err.code, err.message)); return () => { stop(); active--; log('DETACH', label, 'active=', active); };}const lowStock = query(collection(db,'catalogItems'), orderBy('stock','asc'), limit(4));const stopQuery = tracked('low-stock', lowStock);const stopDoc = tracked('p-1001', doc(db,'catalogItems','p-1001'));window.cleanup = () => { stopQuery(); stopDoc(); };</script>
3. Local writes: fast UI, separate acknowledgement
Attach a listener to the anonymous user's order document, then
change only client-writable fields while the network is
disabled. The local cache can immediately show the updated note.
The key evidence is metadata:
hasPendingWrites=true means the snapshot includes a
local write that the backend has not acknowledged. After
reconnect and successful Rules evaluation, a metadata-aware
listener can later report the committed state with pending
writes cleared.
const orderRef = doc(db, `profiles/${uid}/orders/o-live-1`);await setDoc(orderRef, { status:"created", customerNote:"", clientTouchedAt:serverTimestamp() });const stopOrder = onSnapshot(orderRef, { includeMetadataChanges:true }, snap => { console.log("ORDER", snap.data(), snap.metadata.fromCache, snap.metadata.hasPendingWrites);});await disableNetwork(db);await updateDoc(orderRef, { customerNote:"leave at desk", clientTouchedAt:serverTimestamp() });console.log("local write queued; listener should expose pending metadata");await enableNetwork(db);// Keep the page open long enough to observe acknowledgement, then stopOrder().
4. Reproduce deterministic server-side changes
{ "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json", "edition": "standard" }, "emulators": { "firestore": { "port": 8080, "edition": "standard" }, "auth": { "port": 9099 }, "ui": { "enabled": true, "port": 4000 } }}
rules_version = '2';service cloud.firestore { match /databases/{database}/documents { match /catalogItems/{productId} { allow read: if true; allow write: if false; } match /profiles/{uid} { allow read, create, update: if request.auth != null && request.auth.uid == uid; allow delete: if false; match /orders/{orderId} { allow read, create: if request.auth != null && request.auth.uid == uid; allow update: if request.auth != null && request.auth.uid == uid && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['customerNote', 'clientTouchedAt']); allow delete: if false; } } match /{document=**} { allow read, write: if false; } }}
{ "indexes": [ { "collectionGroup": "catalogItems", "queryScope": "COLLECTION", "fields": [ { "fieldPath": "sellerId", "order": "ASCENDING" }, { "fieldPath": "stock", "order": "ASCENDING" } ] } ], "fieldOverrides": []}
process.env.FIRESTORE_EMULATOR_HOST = "127.0.0.1:8080";process.env.GCLOUD_PROJECT = "demo-atlasmart-firestore";import { initializeApp } from "firebase-admin/app";import { getFirestore, Timestamp } from "firebase-admin/firestore";initializeApp({ projectId: "demo-atlasmart-firestore" });const db = getFirestore();const t = Timestamp.fromMillis(1760000000000);const items = { "p-1001": { name:"Trail Camera", sellerId:"seller-a", stock:8, price:99, published:true, updatedAt:t }, "p-1002": { name:"USB-C Hub", sellerId:"seller-a", stock:3, price:49, published:true, updatedAt:t }, "p-1003": { name:"Temp Sensor", sellerId:"seller-b", stock:12, price:39, published:true, updatedAt:t }, "p-1004": { name:"Edge Gateway", sellerId:"seller-b", stock:1, price:149, published:true, updatedAt:t }, "p-1005": { name:"PoE Camera", sellerId:"seller-c", stock:5, price:199, published:true, updatedAt:t }, "p-1006": { name:"Bench PSU", sellerId:"seller-a", stock:9, price:89, published:true, updatedAt:t }};for (const [id, data] of Object.entries(items)) await db.doc(`catalogItems/${id}`).set(data);console.log("seeded catalogItems", Object.keys(items).length);
process.env.FIRESTORE_EMULATOR_HOST = "127.0.0.1:8080";process.env.GCLOUD_PROJECT = "demo-atlasmart-firestore";import { initializeApp } from "firebase-admin/app";import { getFirestore, FieldValue } from "firebase-admin/firestore";initializeApp({ projectId: "demo-atlasmart-firestore" });const db = getFirestore();for (const [id, delta] of [["p-1001",-1],["p-1003",-2],["p-1002",+4],["p-1004",+3]]) { await db.doc(`catalogItems/${id}`).update({ stock: FieldValue.increment(delta), updatedAt: FieldValue.serverTimestamp() }); console.log("pulse", id, delta); await new Promise(r => setTimeout(r, 700));}
mkdir atlasmart-listeners && cd atlasmart-listenersnpm init -ynpm install firebase-admin@14.4.0cat > firebase.json # paste the lesson versioncat > firestore.rules # paste the lesson versioncat > firestore.indexes.json # paste the lesson version# terminal Anpx firebase-tools@15.30.0 emulators:start --project demo-atlasmart-firestore --only firestore,auth# terminal Bnode seed.mjs# terminal C (browser lab served from this directory)python -m http.server 4173# open http://127.0.0.1:4173/listener.html
Run node admin-pulse.mjs while the browser listener
is attached. The low-stock listener will not necessarily emit
one callback per command in a one-to-one timing pattern;
callbacks describe synchronized query state. What you should
verify is stronger: the resulting ordered snapshot matches the
current query, change types/positions are coherent, and the
listener can be detached cleanly.
5. Wrong approach: one listener per visible row
If a screen already has a query listener for 50 products, attaching 50 additional document listeners to “keep each row fresh” duplicates lifecycle complexity and can duplicate read/bandwidth work. Prefer one bounded query listener when one query contract can represent the screen. Use a separate document listener only when a distinct document has a distinct lifecycle or security contract.
Do not drive irreversible side effects from the assumption that a callback occurs exactly once. Reconnects, metadata updates, local writes, and application remounts can cause multiple observations of equivalent state. Durable business processing needs idempotency/event infrastructure, not UI listener callback counts.
Production judgment
Realtime is appropriate when the freshness requirement justifies a long-lived query and the result set is bounded/selective. Measure p95/p99 end-to-end “server mutation → rendered state” separately from initial-load latency and explicitly record client network/cache state. Standard, Enterprise Native Core, and MongoDB-compatible change streams have different billing and API semantics; Pipeline operations do not provide these realtime listeners.
Knowledge check
-
Why are all documents in the first query snapshot often
reported as
added? - What proves a local listener value is not yet server-acknowledged?
-
Can
removedmean something other than deletion? - Why is attaching a listener per row usually suspicious?
- Do Pipeline operations support realtime listeners in Enterprise Native mode?
Review the answers
1. Because the change set describes the transition from an empty local result to the initial current result; it does not mean the documents were just created.
2.
SnapshotMetadata.hasPendingWrites is true for
snapshots containing local uncommitted writes.
3. Yes. A document update can make it stop matching the query, which also removes it from the result set.
4. A bounded query listener often owns the whole screen; per-row listeners multiply read/bandwidth and lifecycle ownership without adding a new data contract.
5. No. Use Core operations for Enterprise realtime listen queries; Pipeline operations are a separate query interface.
Summary and next step
You can now read a listener as synchronized query state rather than an event queue. Lesson 2 focuses on metadata as a user-experience contract: pending versus committed and cache-derived versus server-current.
Authoritative references
-
Get realtime updates with Cloud Firestore
— document/query listeners, initial snapshots,
docChanges(), detach/error behavior. -
Access data offline
— cache behavior,
fromCache, metadata-change events, network transitions. -
JavaScript
SnapshotMetadata—hasPendingWritesandfromCachesemantics. -
JavaScript
SnapshotListenOptions— metadata updates and listen-source options. - Understand Cloud Firestore billing — Standard listener reads, reconnect charging, index-entry reads, Rules-dependent reads, and bandwidth.
- Firestore Enterprise pricing — initial read units and separate realtime-update units.
- Enterprise Core realtime listeners — Core listener support and Pipeline listener boundary.
- Enterprise Native Core/Pipeline overview — Core realtime/offline support versus Pipeline query operations.
-
MongoDB-compatibility change streams
— separate Preview change-stream model; do not equate it with
Web SDK
onSnapshot. - Firebase release notes — current CLI/SDK versions used by this lab.