Chapter 07 · Realtime Listeners: Snapshots, Change Events, Metadata, Latency, and Listener Lifecycle
Unsubscribe / Lifecycle Management in Web / Mobile Frameworks and Avoiding Leaked Listeners
Own and detach Firestore listeners across web/mobile component, route, and authentication lifecycles; instrument active listener counts and prevent subscription leaks.
Learning outcomes
Treat every listener attachment as an owned resource with a deterministic detach path.
Implement cleanup patterns for vanilla JavaScript, React-style effects, route changes, mobile view models, and abortable screen ownership without double-attaching.
Instrument active-listener counts and catch leaks in tests.
Handle terminal listener errors and authentication/user changes without leaving stale subscriptions alive.
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: every route visit adds another “live” dashboard
A seller opens Inventory, navigates to Orders, returns to
Inventory, and repeats. If each mount calls
onSnapshot() but no unmount calls the returned
unsubscribe function, the browser accumulates live
subscriptions. The visible UI may look correct while reads,
callbacks, memory, and side effects multiply.
The Web onSnapshot() API returns an
unsubscribe function. The listener is a
never-ending stream in normal operation; completion callbacks
are not the lifecycle mechanism. Your component/router/view
model owns detachment.
function showInventory() { onSnapshot(query(collection(db,"catalogItems"), orderBy("stock"), limit(20)), snap => { render(snap.docs); // every visit adds another active listener });}router.on("/inventory", showInventory);
2. A tiny ownership registry makes leaks observable
const owners = new Map();export function ownListener(ownerId, attach) { if (owners.has(ownerId)) throw new Error(`listener already owned: ${ownerId}`); const rawStop = attach(); let stopped = false; owners.set(ownerId, () => { if (stopped) return; stopped = true; rawStop(); owners.delete(ownerId); }); return owners.get(ownerId);}export function activeListenerCount() { return owners.size; }export function stopOwner(ownerId) { owners.get(ownerId)?.(); }export function stopAll() { for (const stop of [...owners.values()]) stop(); }
This registry does not change Firestore. It makes application ownership auditable. A production implementation can attach owner type, route, user ID hash, query fingerprint, and attach time, but avoid logging document contents or sensitive query values unnecessarily.
3. Lifecycle patterns by UI architecture
let stopInventory = null;function enterInventory() { exitInventory(); stopInventory = onSnapshot(inventoryQuery, renderInventory, renderError);}function exitInventory() { stopInventory?.(); stopInventory = null;}
useEffect(() => { const q = query(collection(db,"catalogItems"), where("sellerId","==",sellerId), limit(20)); const stop = onSnapshot(q, { includeMetadataChanges:true }, onNext, onError); return () => stop();}, [db, sellerId]); // stable dependencies; do not recreate query from unrelated render state
class InventoryViewModel { #stop = null; start(query, onNext, onError) { this.stop(); this.#stop = onSnapshot(query, onNext, onError); } stop() { this.#stop?.(); this.#stop = null; }}
4. User/auth changes are lifecycle boundaries
A listener authorized for user A should not remain active after sign-out or a switch to user B. Detach user-scoped listeners before replacing UI identity. Firestore Security Rules still protect server access, but stale subscriptions can keep callbacks, cached state, and error paths alive in the client. Treat authentication session changes, route disposal, app background policy, and database-instance teardown as explicit ownership transitions.
5. Terminal errors and retries
The listener error callback is where permission/index/query failures surface. After a terminal error, do not keep a “LIVE” badge lit. Fix the cause before creating a replacement listener; an application-level rapid retry loop around a permanent permission error is an outage amplifier. Transient network recovery is already part of SDK listener behavior.
const stop = onSnapshot(q, {includeMetadataChanges:true}, snap => { setLiveState("active"); render(snap);}, err => { setLiveState("error"); recordListenerError({ code:err.code, query:"inventory.lowStock" }); // Do not spin an immediate manual retry loop for permission-denied/failed-precondition.});
6. Leak test: mount/unmount 100 times, finish at zero
import assert from "node:assert/strict";let active = 0;function fakeAttach(){ active++; let done=false; return ()=>{ if(!done){done=true;active--;} }; }for (let i=0;i<100;i++) { const stop = fakeAttach(); assert.equal(active,1); stop(); assert.equal(active,0); stop(); // idempotent wrapper: still zero}assert.equal(active,0);console.log("PASS listener lifecycle", {active});
Then repeat the same route test in a browser against the emulator using the real registry. The fake unit test proves ownership logic; the emulator test proves integration wiring. Neither proves production billing or mobile OS suspension behavior.
7. Wrong approach: “the browser will clean it up eventually”
Page termination will end network resources, but modern single-page apps keep one page alive through many route transitions. Hidden components can remain mounted, background tabs can persist, and mobile views can have more complex lifecycles. Resource ownership must be deterministic at the application boundary.
8. Production observability
| Metric | Useful interpretation | Guardrail |
|---|---|---|
| Active listeners by screen/query fingerprint | Detect duplicates and high fan-out ownership | Do not include sensitive raw field values |
| Attach/detach delta | Leak detection across navigation/session changes | End-to-end tests must return to baseline |
| Listener error rate by code | Rules/index/auth regressions | Alert on sustained permission/index failures |
| Snapshot/update-to-render latency | User-visible freshness | Segment cache/server and connection state |
| Reconnect rate | Network/background churn and potential read cost | Correlate with platform/app version |
Production judgment
Listener lifecycle is part of correctness, security hygiene, cost control, and performance. A screen that cannot name who owns each listener and when it detaches is not production-ready. Prefer one query-level owner per coherent live view; derive row UI from that state instead of hiding subscriptions inside row components.
Knowledge check
-
What is the canonical Web detach mechanism for
onSnapshot()? - Why is a completion callback not a cleanup strategy?
- What should happen to user-scoped listeners on sign-out?
-
Why can an automatic retry loop around
permission-deniedbe harmful? - What simple invariant catches many UI listener leaks?
Review the answers
1. Call the unsubscribe function returned
by onSnapshot().
2. Firestore snapshot listeners are intended as never-ending streams; lifecycle cleanup is explicit unsubscribe.
3. Detach them as part of the identity lifecycle before presenting another user session.
4. It repeats a permanent failure, creates noise/load, and hides the real Rules/auth defect.
5. After repeated mount/unmount or route transitions, active listener count returns to the pre-test baseline, ideally zero for the disposed screen.
Summary and next step
You can now prove that listeners have owners and finite lifetimes. Lesson 5 combines query design, metadata, offline/online state, pagination, security, and cost controls into one production-oriented realtime screen.
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.