Chapter 07 · Realtime Listeners: Snapshots, Change Events, Metadata, Latency, and Listener Lifecycle
Design a Realtime Screen with Correct Offline / Online State, Security, Pagination, and Cost Controls
Design a production-oriented Firestore realtime screen with bounded listeners, offline/online labels, Rules-compatible queries, cursor pagination, lifecycle cleanup, and cost controls.
Learning outcomes
Design a realtime AtlasMart screen whose live region is bounded and whose historical region uses cursor pagination instead of an unbounded listener.
Expose cached/pending/server-current state honestly and stop listeners on route/user lifecycle changes.
Keep Security Rules/query constraints compatible and separate client Rules from trusted Admin/IAM execution.
Create a listener runbook with cost controls, failure injection, observability, and an explicit polling/event-infrastructure escape hatch.
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. Capstone problem: live current order, paged history, selective inventory
AtlasMart's order center has three needs with different freshness targets: the current order status should update quickly; order history can be paged; seller inventory only needs the lowest-stock items for the logged-in seller. The correct design is not “listen to all orders and all products forever.” It assigns a separate read contract to each region.
| Screen region | Read contract | Lifecycle | Why |
|---|---|---|---|
| Current order card | One document listener | Attach while card visible/current order exists | Small live surface; domain status changes matter |
| Seller low-stock panel | Bounded selective query listener | Attach on seller dashboard; detach on exit | Fresh top-N operational view |
| Order history | One-shot cursor pages | Fetch on initial load/scroll | Past orders do not need constant live churn |
| Analytics totals | Aggregation/analytics path | Scheduled/on demand | Do not maintain huge client listener for analytics |
2. Security/query contract first
Mobile/Web listeners are evaluated through Firestore Security Rules. Rules are not post-filters: the query must be provably compatible with allowed access. A broad collection listener that would include other users' orders cannot be made safe by filtering unauthorized rows in JavaScript. Model the path/query so the backend can authorize the entire requested result.
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; } }}
Trusted Admin/server code is different: server libraries authenticate with IAM/credentials and bypass mobile/Web Security Rules. Never move the same client rule assumptions into an Admin service. The emulator Admin pulse is trusted intentionally so it can simulate fulfillment/inventory changes.
3. Build the current-order + inventory live regions
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 state={stops:new Map(),live:{},history:[]};function own(name, attach) { state.stops.get(name)?.(); const stop=attach(); state.stops.set(name,stop); }function stopAll() { for(const stop of state.stops.values()) stop(); state.stops.clear(); }function label(meta) { return meta.hasPendingWrites ? "saving" : (meta.fromCache ? "cached" : "synced"); }const orderRef=doc(db,`profiles/${uid}/orders/o-live-1`);await setDoc(orderRef,{status:"created",customerNote:"",clientTouchedAt:serverTimestamp()},{merge:true});own("current-order",()=>onSnapshot(orderRef,{includeMetadataChanges:true},snap=>{ state.live.order={data:snap.data(),sync:label(snap.metadata)}; render(state);},showError));const lowStock=query(collection(db,"catalogItems"),where("sellerId","==","seller-a"),orderBy("stock","asc"),limit(5));own("low-stock",()=>onSnapshot(lowStock,{includeMetadataChanges:true},snap=>{ state.live.stock={rows:snap.docs.map(d=>({id:d.id,...d.data()})),sync:label(snap.metadata)}; render(state);},showError));window.atlasmartDispose=stopAll;
4. History is pagination, not an unbounded live listener
Chapter 05 established stable cursor pagination. Keep that contract. Load a page of historical orders with a deterministic order and cursor, then optionally refresh on user action. If one historical order becomes “current,” attach a document listener to that order rather than turning the entire history into a listener.
const pageQ = query( collection(db, `profiles/${uid}/orders`), orderBy("createdAt","desc"), orderBy(documentId()), limit(20));const page = await getDocs(pageQ);// Store the final DocumentSnapshot as the next cursor.// Next page: startAfter(page.docs.at(-1)) with the exact same ordering.
5. Offline/online truth table
| Observed state | Current-order UX | Allow irreversible action? |
|---|---|---|
| pending=true | Show optimistic value + “Saving…” | Usually no if action requires confirmed inventory/payment |
| cache=true, pending=false | Show cached value + stale/offline indicator | Only if product contract tolerates stale data |
| cache=false, pending=false | Show synced value | Still validate domain invariant server-side |
| listener error | Stop “live” indicator; show recoverable/error state | No; diagnose auth/rules/index/network cause |
Chapter 08 will go deeper into persistence and conflict behavior. Here the rule is simpler: live UI must tell the truth about whether state is local/cached/server-current.
6. Cost/control budget before deployment
screen: order-centeredition: standardmode: native-corelive_regions: - name: current-order shape: document max_per_screen: 1 - name: low-stock shape: query filters: sellerId == currentSeller order: stock asc limit: 5non_live_regions: - name: order-history shape: cursor-pages page_size: 20controls: detach_on_route_exit: true detach_on_sign_out: true metadata_changes: true active_listener_metric: true reconnect_metric: true production_budget_alerts: truenotes: - Standard listener pricing and Rules-dependent reads must be rechecked for deployment region. - Enterprise uses read units + realtime-update units; do not reuse Standard estimates.
7. Failure-injection matrix
| Injection | Expected evidence | Cleanup |
|---|---|---|
| Disable SDK network, edit note | pending metadata; cached/local UI; no false “server saved” | Re-enable; await acknowledgement/rejection |
| Admin changes seller-b item | seller-a scoped listener does not churn unless result contract affected | Reset fixture |
| Admin changes seller-a low-stock item | ordered query snapshot updates coherently | Reset fixture |
| Navigate away/back repeatedly | active-listener count returns to baseline each exit | Call dispose; assert zero |
| Temporary deny Rules for order update | client write/listener error path is visible; server Admin unaffected by client Rules | Restore Rules; restart clean emulator |
8. When not to use a realtime listener
- Use one-shot reads/pagination when periodic freshness is acceptable.
- Use server-side scheduled materialization/aggregation when the client would otherwise watch huge collections for totals.
- Use durable event/messaging systems for exactly-once-like processing requirements, retention, replay, consumer groups, or irreversible side effects; the Firestore listener is synchronized state, not a durable event queue.
- In Enterprise Native, use Core operations if realtime/offline is needed; Pipeline queries themselves do not support realtime listeners.
- For MongoDB compatibility, evaluate its Preview change-stream feature separately; do not port Web SDK metadata assumptions.
9. Acceptance checklist
- Start Firestore/Auth emulators with the pinned Standard config and seed the catalog.
- Open the order-center app; confirm exactly two live owners: current order and seller-a low stock.
- Run Admin pulses and verify only query-relevant changes affect the seller panel.
- Disable network, write a customer note, and verify pending/cached labels; re-enable and observe resolution.
- Navigate/dispose and assert zero owned listeners.
- Fetch history by cursor pages, not a history-wide listener.
- Record what the emulator did not prove: production index enforcement, billing, WAN reconnect behavior, p95/p99 latency, background mobile lifecycle.
- For a managed disposable project only, capture actual usage/billing telemetry and budget alerts before extrapolating cost.
Production judgment and runbook
Ship only after you can answer: Which regions are live? Who owns each listener? What is the maximum result size? What does cached/pending mean in the UI? Which Rules authorize the query? What happens after sign-out/route exit? What reconnect rate do you observe? What is the Standard or Enterprise cost model? What metric tells you freshness is degrading? What is the fallback if realtime fan-out becomes uneconomic?
Backup/recovery does not preserve a user's active listener session; after recovery/restart, clients establish new read state. Therefore recovery testing must validate that Rules, indexes, data, and app versions still permit the expected query/listener contracts.
Knowledge check
- Why is the order-history region paged instead of live?
- Why can’t client-side filtering make a broad orders listener secure?
- What two listener ownership events are mandatory in this capstone?
- What should a screen do when a listener returns a terminal error?
- What is the Chapter 08 bridge?
Review the answers
1. Its freshness requirement is weaker; cursor pages bound reads and lifecycle while the current order gets the focused realtime listener.
2. Security Rules are not post-filters; the backend must be able to prove the whole query is authorized.
3. Route/view disposal and authentication/session changes such as sign-out.
4. Stop claiming the region is live, surface/recover appropriately, record the error, and fix auth/rules/index/query cause before reattaching.
5. Offline persistence, local-cache durability, conflict behavior, multi-tab/process semantics, and the distinction between local writes and server transactions.
Summary and next step
Chapter 07 ends with a bounded, metadata-aware, lifecycle-owned realtime design. Chapter 08 keeps the same listener observations but removes the assumption of continuous connectivity and studies cache persistence, queued writes, conflicts, and offline indexes explicitly.
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.