With usage-billed database services like Firestore, cost optimization starts with your code. It's not just that applications read and write documents; applications spend. Every get(), every listener, every document touched contributes to your bill—and Firestore bills per document operation, which makes it unusually honest about waste. Read a thousand documents to show ten, and you bought a thousand reads. The bill knows.
Taking an application- and code-centric approach to cost reduction means understanding what your code touches on each operation. Here's a practical walk-through of what to look for and how to optimize Firestore costs at the code and data-model level.
| Cost Trap | Efficiency Pattern |
|---|---|
| Uncached repeated reads | Cache hot documents in the application |
| Document-at-a-time writes | Use batched writes and BulkWriter |
| Fetching collections to filter in the client | Let queries do the filtering |
| Reading every document to count or sum | Use aggregation queries |
| Unbounded queries with no limit | Paginate with limits and cursors |
| Polling for changes with repeated gets | Use listeners; pay for changes, not checks |
| Listeners attached wide and left running | Scope listeners narrowly; detach when idle |
| Oversized documents | Keep documents lean; blobs belong in Cloud Storage |
| Expired data that never leaves | Enable TTL policies |
| Fetching full documents for a few fields | Use select() field masks on server-side reads |
Attribute the Costs
Start with your bill. Break down costs by operation type, collection, and application to understand where spend concentrates. Firestore charges per document operation: reads (~$0.06 per 100k in US multi-region), writes (~$0.18 per 100k), deletes (~$0.02 per 100k), plus storage (~$0.18/GiB-month) and network egress (~$0.12/GB). The Firestore usage dashboard and Cloud Monitoring break operations down over time; a spiky read graph that dwarfs your actual user activity is the signature of a hot loop, a polling job, or a collection fetched wholesale.
Do the sanity math early: it's the fastest attribution tool you have. A dashboard that reads a 50,000-document collection to render a count, refreshed every 30 seconds by 20 concurrent users, is ~2.9 billion reads a month—about $1,700 for a number that an aggregation query delivers for pennies. When the bill and the user count don't rhyme, some code path is touching documents nobody looks at. This granular attribution reveals which of the 10 efficiency patterns below deliver the highest impact.
Tackling Read Costs
Reads dominate most Firestore bills, and most wasted reads come from four habits.
Cache hot documents
Cost impact: Reads
The same configuration, profile, or reference documents read on every request bill full price every time:
// Bad: read settings on every request
async function handle(req) {
const snap = await db.doc(`tenants/${req.tenantId}/meta/settings`).get();
...
}
// Good: cache with a TTL
async function handle(req) {
let settings = cache.get(`settings:${req.tenantId}`);
if (!settings) {
const snap = await db.doc(`tenants/${req.tenantId}/meta/settings`).get();
settings = snap.data();
cache.set(`settings:${req.tenantId}`, settings, 300);
}
...
}
Benefit: 30-70% reduction in reads for cache-friendly access patterns.
Let queries do the filtering
Cost impact: Reads
Fetching a collection and filtering in the client bills a read for every document fetched—including all the ones the filter throws away:
// Bad: read 20,000 documents, keep 40
const snap = await db.collection('orders').get();
const pending = snap.docs.filter(d => d.data().status === 'PENDING');
// Good: read 40 documents
const snap = await db.collection('orders')
.where('status', '==', 'PENDING')
.get();
If a query needs an index Firestore doesn't have, the console error hands you the exact index to create. Create it—the index is vastly cheaper than reading the collection.
Benefit: Reads drop from collection-size to result-size—commonly 99%+ on affected paths.
Use aggregation queries for counts and sums
Cost impact: Reads
The oldest Firestore tax: reading every document to compute a number.
// Bad: 50,000 reads to display "50,000"
const snap = await db.collection('orders').get();
const total = snap.size;
// Good: one aggregation query
const agg = await db.collection('orders').count().get();
const total = agg.data().count;
count(), sum(), and average() bill one read per 1,000 index entries scanned—a 50,000-document count costs 50 reads instead of 50,000. For counters displayed constantly, cache the aggregate too.
Benefit: 99%+ reduction in reads for counts and aggregates.
Paginate everything that grows
Cost impact: Reads
An unbounded query bills every matching document, and "matching" grows with the business:
// Bad: entire history, every visit
const snap = await db.collection(`users/${uid}/events`)
.orderBy('ts', 'desc').get();
// Good: a page, with a cursor for the rest
const snap = await db.collection(`users/${uid}/events`)
.orderBy('ts', 'desc').limit(25).get();
// next page: .startAfter(lastDoc).limit(25)
Benefit: Reads per view drop from history-size to page-size, permanently.
Tackling Realtime Costs
Firestore's realtime features are cost features—used right.
Listen, don't poll
Cost impact: Reads
Polling re-reads the full result set on every tick whether anything changed or not. A listener bills the initial read once, then only the documents that actually change:
// Bad: 120 reads/minute per client to check for changes
setInterval(async () => {
const snap = await db.collection('rooms/lobby/messages')
.orderBy('ts', 'desc').limit(20).get();
render(snap);
}, 10_000);
// Good: pay once, then only for changes
db.collection('rooms/lobby/messages')
.orderBy('ts', 'desc').limit(20)
.onSnapshot(snap => render(snap));
Benefit: Read volume drops from poll-rate × result-size to actual change volume.
Scope listeners narrowly—and detach them
Cost impact: Reads
The listener trap runs the other direction too: a snapshot listener on a broad query bills a read for every changing document in scope, whether the user is looking or not. A listener on a whole collection of live telemetry, attached at app start and never detached, is a read-billing machine:
// Bad: app-wide listener on everything, forever
db.collection('vehicles').onSnapshot(handle);
// Good: listen to what this screen shows, detach on exit
const unsubscribe = db.collection('vehicles')
.where('fleetId', '==', fleetId).limit(50)
.onSnapshot(handle);
// on unmount:
unsubscribe();
Benefit: Reads track what users actually watch instead of everything that moves.
Tackling Write & Storage Costs
Batch writes
Cost impact: Round-trips and compute time
Write operations bill per document either way, but a loop of single set() calls pays a network round-trip per document and stretches function duration while it waits:
// Bad: 500 sequential round-trips
for (const item of items) {
await db.collection('inventory').doc(item.sku).set(item);
}
// Good: BulkWriter parallelizes and retries for you
const writer = db.bulkWriter();
for (const item of items) {
writer.set(db.collection('inventory').doc(item.sku), item);
}
await writer.close();
Use WriteBatch when the writes must commit atomically (up to 500 operations); BulkWriter for throughput.
Benefit: 10-100x fewer round-trips; less compute time spent waiting on I/O.
Keep documents lean
Cost impact: Reads, writes, storage, and egress
A read bills the whole document no matter how little of it you wanted—size taxes every operation. Embedded base64 images, accumulated history arrays, and denormalized everything make each touch more expensive:
// Bad: 800 KB document with an embedded thumbnail
await db.doc(`products/${id}`).set({ name, price, thumb: base64Image });
// Good: document holds the pointer, Cloud Storage holds the bytes
await bucket.file(`thumbs/${id}.jpg`).save(imageBuffer);
await db.doc(`products/${id}`).set({ name, price,
thumbUrl: `gs://thumbs/${id}.jpg` });
Growing lists (events, messages, history) belong in subcollections, where you read a page—not in arrays that ride along with every parent read. And on server-side reads that need only a few fields, select('name', 'price') trims the egress as well.
Benefit: Every operation on the document gets cheaper; storage moves to Cloud Storage at a fraction of the per-GiB price.
Enable TTL on expiring data
Cost impact: Storage and reads
Sessions, presence records, ephemeral events—without TTL they accumulate at storage rates until a cleanup job reads them to find them and pays deletes to remove them. A TTL policy on a timestamp field expires them server-side, no sweep required:
await db.collection('sessions').doc(sid).set({
data: payload,
expireAt: Timestamp.fromMillis(Date.now() + 86_400_000) // TTL field
});
Benefit: Storage stops growing unboundedly; the read-and-delete sweep disappears.
Closing Thoughts
Cost-effective Firestore requires two steps. First, let observed costs guide what needs optimization. Attribute your bill down to operation types and the code paths behind them—when read volume doesn't rhyme with user activity, find the collection fetch, the polling loop, or the app-wide listener responsible. This granular attribution reveals which of the 10 efficiency patterns above matter most for your workloads. Second, apply the optimizations that address your cost concentrations: cache hot documents, filter in the query instead of the client (99%+ on affected paths), aggregate instead of enumerate, paginate everything that grows, listen instead of poll—and scope the listeners, batch writes, keep documents lean, and let TTL take out the trash.
You don't lose the realtime magic—you aim it. Firestore bills you for every document you touch; the discipline is touching only the ones the user actually needs.
Looking for help with cost optimizations like these? Book a Demo or Explore the Sandbox to see Frugal in action. Frugal attributes GCP Firestore costs to your code, finds inefficient usage, provides Frugal Fixes that reduce your bill, and helps keep you out of cost traps for new and changing code.