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.

Intermediate120–145 minutesInitial + incremental snapshotsFirebase JS 12.19.0 · CLI 15.30.0Last reviewed: September 2026

Learning outcomes

01

Distinguish a document listener from a query listener and treat each as a long-lived read contract rather than a push-notification subscription.

02

Explain why the initial query snapshot contains added changes for the existing result set, then interpret later added, modified, and removed transitions.

03

Observe latency-compensated local writes with hasPendingWrites and preserve query ordering as documents enter, move within, or leave a result set.

04

Instrument listener ownership and unsubscribe paths so navigation does not silently multiply live reads.

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 07 reproducibility baseline · reviewed 15 September 2026

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.

Evidence boundary

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.

listener.html · instrument query + one document
<!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.

append inside the browser module
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

firebase.json
{  "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json", "edition": "standard" },  "emulators": {    "firestore": { "port": 8080, "edition": "standard" },    "auth": { "port": 9099 },    "ui": { "enabled": true, "port": 4000 }  }}
firestore.rules
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; }  }}
firestore.indexes.json
{  "indexes": [    {      "collectionGroup": "catalogItems",      "queryScope": "COLLECTION",      "fields": [        { "fieldPath": "sellerId", "order": "ASCENDING" },        { "fieldPath": "stock", "order": "ASCENDING" }      ]    }  ],  "fieldOverrides": []}
seed.mjs
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);
admin-pulse.mjs
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));}
setup
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.

Exactly-once is not a listener guarantee.

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

  1. Why are all documents in the first query snapshot often reported as added?
  2. What proves a local listener value is not yet server-acknowledged?
  3. Can removed mean something other than deletion?
  4. Why is attaching a listener per row usually suspicious?
  5. 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

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.