Firestore bills per operation — every document read, write, and delete is a metered event. This makes cost a property of your data model and listener design, not your traffic. Two apps with identical user counts can differ in Firestore spend by 50x, and the difference is almost always one of a small set of amplification patterns. Here are the ones we find most often in billing reviews, roughly in order of how much they cost.
1. Listeners scoped wider than the UI
The pattern: a screen shows the five most recent messages, and the code attaches a listener to the whole collection.
// Renders 5 rows, bills for every message ever sent
onSnapshot(collection(db, 'rooms', roomId, 'messages'), render);
// Bills for 25
onSnapshot(
query(collection(db, 'rooms', roomId, 'messages'),
orderBy('sentAt', 'desc'), limit(25)),
render);
The first version reads every document in the collection on attach, and re-reads grow with the collection forever. Audit every onSnapshot and getDocs for a missing limit() — it is the single highest-yield check in a cost review.
A related sub-pattern: listeners attached on app start for data the user may never view. Attach listeners when the screen mounts, detach when it unmounts. "We might need it" is a read bill, not a cache strategy.
2. Re-reading unchanged data on navigation
Client apps that call getDocs in every route handler re-bill the same documents each time the user navigates. Firestore's local cache only helps if you let it: for data that tolerates a little staleness, read from cache first and refresh selectively, or use a listener (whose incremental updates only bill changed documents) instead of repeated one-shot gets. On web, getDocsFromCache plus a background refresh turns five billed reads per navigation into five billed reads per session.
3. Fan-out documents that rewrite the world
Storing an array or map of "everything the user follows" in one document means every follow rewrites the whole document — and every index entry on it. Worse is the mirrored version: a feed document per follower, rewritten on every post. Fan-out on write is sometimes the right design, but it must be incremental — one small document per event — not a rewrite of a large aggregate per event. Watch for update calls whose payload is a large array that changed by one element; each is billing you for the whole array's index maintenance.
4. Counters read at render time, computed at read time
Displaying "1,204 members" by fetching the members collection and counting it bills 1,204 reads to render one integer. The aggregation queries (count(), sum(), avg()) improve this dramatically — they bill one read per 1,000 index entries scanned rather than one per document — and are the right first answer. For counts displayed on hot paths, maintain a materialized counter updated by a Function trigger or scheduled rollup, and accept the write cost once instead of the read cost per render.
5. Polling jobs against the production database
Scheduled jobs that scan a collection every minute looking for work (where('status', '==', 'pending')) bill the scan whether or not work exists, forever. Replace polling with the mechanism Firestore already gives you — a Function triggered on document create/update — or with Cloud Tasks for delayed execution. If a periodic scan is genuinely required, make it incremental with a cursor on updatedAt so each run reads only what changed since the last.
6. Analytics queries against production prices
The most expensive dashboards we have seen were built on Firestore reads: a nightly job (or worse, a BI tool) scanning entire collections to compute aggregates. Firestore is priced for operational reads, not analytical scans. Stream changes to BigQuery — the Firestore-to-BigQuery extension makes this nearly configuration-only — and point every analytical question there. BigQuery scans of the same data typically cost orders of magnitude less, and your production latency stops depending on an analyst's curiosity.
Making the bill observable
You cannot fix amplification you cannot see. Three instruments, all cheap to set up:
- Usage dashboards in the console show reads/writes/deletes per day. A step change in reads that doesn't match a step change in users is amplification shipping.
- Billing export to BigQuery breaks cost down by SKU and day, and lets you alert on trends rather than monthly totals.
- Client-side read accounting in staging — a wrapper around your Firestore calls that increments a per-screen counter — turns "reads per session" into a number a code review can discuss. If a screen costs 400 reads to render, someone should have to defend that in review.
Set a budget alert well below the pain threshold, and set a second one at 50% of the first with a shorter evaluation window. The goal is to catch a regression the week it ships. Every pattern above is straightforward to fix when it is one sprint old and miserable to untangle after a year of features have been built on top of it — the observability is what buys you the early catch.