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.

Intermediate110–135 minutesLifecycle + leak preventionFirebase JS 12.19.0 · CLI 15.30.0Last reviewed: September 2026

Learning outcomes

01

Treat every listener attachment as an owned resource with a deterministic detach path.

02

Implement cleanup patterns for vanilla JavaScript, React-style effects, route changes, mobile view models, and abortable screen ownership without double-attaching.

03

Instrument active-listener counts and catch leaks in tests.

04

Handle terminal listener errors and authentication/user changes without leaving stale subscriptions alive.

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: 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.

wrong.js · leaked listener
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

listener-registry.js
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

vanilla route lifecycle
let stopInventory = null;function enterInventory() {  exitInventory();  stopInventory = onSnapshot(inventoryQuery, renderInventory, renderError);}function exitInventory() {  stopInventory?.();  stopInventory = null;}
React-style effect
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
view-model / controller ownership
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.

error-aware owner
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

lifecycle-contract.test.mjs
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

  1. What is the canonical Web detach mechanism for onSnapshot()?
  2. Why is a completion callback not a cleanup strategy?
  3. What should happen to user-scoped listeners on sign-out?
  4. Why can an automatic retry loop around permission-denied be harmful?
  5. 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

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.