+1 (762) 572-1994

What a Snapshot Listener Actually Costs: Listener Reads, Persistent Cache, and Bundles

Most Firestore bills we audit are not caused by a bad query. They are caused by a good query attached to onSnapshot and mounted on a screen users open forty times a day.

The billing model for listeners is simple once you have seen it written down. Almost nobody has seen it written down.

How a listener is billed

Three rules:

  1. The initial snapshot bills one read per document returned. A listener on a 50-document query costs 50 reads the moment it attaches.
  2. Each subsequent change bills one read per changed document, not per document in the result set. One edit to one doc in that 50-doc query is 1 read, not 50.
  3. Re-attaching bills the initial snapshot again — unless the listener can resume from a cached resume token, which only happens under specific conditions covered below.

Rule 3 is where the money goes. A listener that detaches and re-attaches on every navigation is paying full price for the result set every single time.

The arithmetic

A dashboard listens to the last 50 orders. A user opens the dashboard, navigates to an order detail, comes back, navigates away again — eight round trips in a session.

8 mounts x 50 docs = 400 reads per session
+ ~30 live change events        =  30 reads
                                 ---
                                  430 reads/session

At 5,000 sessions a day: 2.15M reads/day, roughly 64M reads/month. At $0.03 per 100k document reads that is about $19/month for one widget. Add nine more widgets with the same shape and you have a $200/month line item that your team believes is "just Firestore being expensive."

It is not Firestore being expensive. It is 400 of those 430 reads being the same 50 documents, re-fetched.

Fix 1: stop unmounting the listener

The cheapest fix is architectural and costs nothing to deploy: hoist the listener above the component that unmounts.

// Bad: listener lives and dies with the route.
function Dashboard() {
  useEffect(() => onSnapshot(q, setOrders), []);
}

// Better: one listener in a provider that outlives navigation.
const store = new Map();

function subscribe(key, q) {
  let entry = store.get(key);
  if (!entry) {
    entry = { refs: 0, unsub: null, data: null, listeners: new Set() };
    store.set(key, entry);
  }
  entry.refs++;
  if (!entry.unsub) {
    entry.unsub = onSnapshot(q, (snap) => {
      entry.data = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
      entry.listeners.forEach((fn) => fn(entry.data));
    });
  }
  return () => {
    entry.refs--;
    if (entry.refs === 0) {
      // Grace period: do not tear down on a navigation bounce.
      setTimeout(() => { if (entry.refs === 0) { entry.unsub(); entry.unsub = null; } }, 60_000);
    }
  };
}

That ref-counted store with a grace period took the example above from 430 reads per session to about 80. Same UI, same freshness, one line item.

Fix 2: understand what the persistent cache actually saves

The web SDK's persistent cache (IndexedDB) is often sold internally as "so we are not billed twice." Be precise about what it does.

import { initializeFirestore, persistentLocalCache, persistentMultipleTabManager } from 'firebase/firestore';

const db = initializeFirestore(app, {
  localCache: persistentLocalCache({ tabManager: persistentMultipleTabManager() }),
});

What it gives you:

  • Offline reads and instant first paint. The cached result renders before the server responds.
  • Resumable listeners. When a query can be resumed from a stored resume token, re-attaching bills only the documents that changed since the token — not the whole result set. This is the real saving, and it is why persistence plus a long-lived listener beats either alone.
  • Cross-tab sharing with the multi-tab manager, so five open tabs share one backing store instead of five independent listeners.

What it does not give you:

  • It does not make a fresh getDocs() free. A server-sourced get bills per document, cache or no cache. Only getDocsFromCache() skips the server, and it will happily serve stale data with no indication of age.
  • It does not survive a cleared browser, incognito, or most mobile web sessions. Treat it as an optimization, never as a correctness assumption.
  • It does not help the first visit at all, which is exactly the session you care about for a marketing-adjacent surface.

Resume-token reuse is also not guaranteed. Change the query shape, stay offline past the retention window, or let the SDK decide the resume is too expensive, and you pay for a fresh initial snapshot. Budget for the pessimistic case.

Fix 3: shared, slow-moving data belongs in a bundle

If every user loads the same 200 documents — product catalog, pricing tiers, public leaderboard, feature content — you are billing 200 reads per user for data that is identical for all of them. Serve it as a Firestore bundle from a CDN instead.

Build it server-side, on a schedule:

const { getFirestore } = require('firebase-admin/firestore');

async function buildCatalogBundle() {
  const db = getFirestore();
  const snap = await db.collection('products').where('active', '==', true).get();
  const bundle = db.bundle('catalog-v1')
    .add('active-products', snap); // named query, resolvable on the client
  return bundle.build(); // Buffer -> upload to Cloud Storage / your CDN
}

On the client:

import { loadBundle, namedQuery, getDocsFromCache } from 'firebase/firestore';

const res = await fetch('https://cdn.example.com/catalog-v1.bundle');
await loadBundle(db, await res.arrayBuffer());
const q = await namedQuery(db, 'active-products');
const snap = await getDocsFromCache(q); // zero billed reads

The documents land in the local cache as if they had been read, and getDocsFromCache bills nothing. You pay one CDN request per user instead of 200 Firestore reads. Rebuild the bundle every N minutes in a scheduled function and version the filename so the CDN caches it hard.

The tradeoff is honest and worth stating: bundle data is as stale as your rebuild interval, and bundles bypass security rules at load time — the bundle contains whatever the Admin SDK read. Only put data in a bundle that every authenticated user, or the public, is allowed to see. Per-user data never goes in a bundle.

A decision procedure

  • Same data for all users, changes slower than minutes → bundle it, serve from CDN, getDocsFromCache.
  • Per-user data, UI genuinely needs live updates → one long-lived ref-counted listener plus persistent cache.
  • Per-user data, UI does not need live updates → getDocs() once with your own TTL in memory. A listener you do not need is a subscription to your own invoice.
  • Large result set, small change rate → listener is correct; the initial snapshot is the cost, so do not re-attach it.

What to measure first

Before changing anything, get the number. Firestore usage in the Cloud Console breaks reads down by database; pair it with a client-side counter in a staging build that increments on every snap.docChanges().length and every mount. One afternoon of instrumentation tells you which three screens own 80% of your reads. In every cost engagement we have run, it has been three screens.

If your reads per session are a mystery and the invoice keeps climbing, that measurement is the first thing a Firebase audit produces.