Firestore's pricing is not a footnote you read after shipping. It is a constraint that propagates all the way up into your schema, your query layer, and your React effects. The unit of billing is one document operation, not one query, not one byte, not one connection. Once you internalize that single fact, most of the "surprise Firestore bill" stories become predictable.
The billing model in one paragraph
You pay for document reads, writes, and deletes by count; for stored bytes by GiB-month; and for egress. A query returning 100 documents costs 100 reads regardless of document size. A query returning 0 documents costs a minimum charge, not zero. Indicative Blaze rates (us multi-region) are roughly $0.06 / 100k reads, $0.18 / 100k writes, $0.02 / 100k deletes, $0.18 / GiB-month storage. Free tier is 50k reads / 20k writes / 20k deletes per day, 1 GiB stored.
Those read numbers look cheap until you multiply them by users × screens × documents per screen.
N+1 is not a latency problem here, it is a line item
In an RDB, N+1 is a latency and connection-pool concern. In Firestore it is literally arithmetic on your invoice:
// 20 posts
const postsSnapshot = await db.collection('posts').limit(20).get();
for (const doc of postsSnapshot.docs) {
const post = doc.data();
// + 1 read each, serially
const userDoc = await db.collection('users').doc(post.authorId).get();
render(post.title, userDoc.data().name);
}
40 reads per timeline render. At 1,000 daily active users opening the timeline three times each, that is 120,000 reads/day — you blew through the free tier before lunch, and you did it to display a display name.
Three escape hatches, in increasing order of commitment:
1. Batch the lookups. Collect the unique author IDs and use an in query. Same read count for the user docs, but one round trip instead of N, and deduplication actually cuts reads when authors repeat:
const ids = [...new Set(posts.map(p => p.authorId))];
// `in` takes up to 30 values per query
const chunks = chunk(ids, 30);
const users = (await Promise.all(
chunks.map(c => db.collection('users').where('__name__', 'in', c).get())
)).flatMap(s => s.docs);
2. Denormalize the display fields into the parent. This is the NoSQL-native answer:
// posts/post_123
{
"title": "Firestore cost model",
"author": { "id": "user_abc", "name": "Taro Yamada", "avatarUrl": "https://..." }
}
20 reads instead of 40. The cost moves to writes: renaming a user means fanning out an update across their historical posts. That trade is good when reads dominate writes by an order of magnitude, which for a social timeline they do. It is bad for fields that change often or must be strictly consistent — do not denormalize an email address you use for auth decisions.
3. Accept the join and cache it. Author documents are small, stable, and heavily reused. The client SDK cache handles this for free (below).
Never count by fetching
The single most expensive mistake is computing a count client-side:
// 1,000 likes = 1,000 reads, to display the number "1000"
const likes = await db.collection('posts').doc(id).collection('likes').get();
return likes.size;
Use the aggregation query instead — it is billed at one read per 1,000 index entries scanned, not per document:
import { getCountFromServer } from 'firebase/firestore';
const snap = await getCountFromServer(db.collection('posts').doc(id).collection('likes'));
return snap.data().count;
For hot counters that appear in list views, keep a materialized likeCount field on the post and maintain it with FieldValue.increment(1). A single document has a sustained write limit of roughly 1 write/second, so shard the counter if a post can go viral.
onSnapshot bills you while you are not looking
get() costs money when you ask. onSnapshot costs money when the data changes — which is a fundamentally different risk profile, because the cost is driven by other people's write traffic and by how long your tab stays open.
The listener charges the initial result set once, then one read per changed document delivered. The failure mode compounds:
- A post document carries a
likeCountthat updates in real time. - 100 users like it → 100 document updates → 100 reads delivered to each open listener.
- 1,000 users have that screen open → 100,000 reads in a few seconds.
Rules that keep this bounded:
- Use
onSnapshotonly where realtime is the product (chat, presence, collaborative editing). Settings screens, archives, and list views getget(). - Always unsubscribe. A leaked listener is a meter that keeps running after the user navigated away:
useEffect(() => {
const unsubscribe = onSnapshot(docRef, snap => setData(snap.data()));
return () => unsubscribe(); // not optional
}, [docRef]);
- Keep volatile fields out of documents that are widely listened to. If
likeCountlives on the post document, every like re-delivers the whole post to every listener. Move it to a sibling document that only the detail view subscribes to. - Detach listeners on
visibilitychangefor background tabs if your app is long-lived.
Client cache is a first-class cost lever
The web and mobile SDKs will serve repeat reads from local storage instead of the network:
import { initializeFirestore, persistentLocalCache } from 'firebase/firestore';
const db = initializeFirestore(app, {
localCache: persistentLocalCache(),
});
Navigating back to a screen you already loaded becomes free. You can also opt individual reads into cache-first behavior with getDocFromCache, with a network fallback. This is the cheapest optimization available and it requires no schema change.
Provisioning it with Terraform
Firestore, its composite indexes, and its security rules are all declarable. Note deletion_policy — set it to ABANDON/delete-protected in production so a terraform destroy in the wrong workspace does not take the database with it.
resource "google_firestore_database" "database" {
project = var.project_id
name = "(default)"
location_id = "asia-northeast1"
type = "FIRESTORE_NATIVE"
concurrency_mode = "OPTIMISTIC"
app_engine_integration_mode = "DISABLED"
deletion_policy = "ABANDON"
}
resource "google_firestore_index" "posts_status_created_at" {
project = google_firestore_database.database.project
database = google_firestore_database.database.name
collection = "posts"
fields {
field_path = "status"
order = "ASCENDING"
}
fields {
field_path = "created_at"
order = "DESCENDING"
}
}
resource "google_firebaserules_ruleset" "firestore" {
project = var.project_id
source {
files {
name = "firestore.rules"
content = file("${path.module}/firestore.rules")
}
}
}
resource "google_firebaserules_release" "firestore" {
project = var.project_id
name = "cloud.firestore"
ruleset_name = google_firebaserules_ruleset.firestore.name
}
FIRESTORE_NATIVE versus DATASTORE_MODE is the one decision you cannot walk back: the mode is fixed at database creation. Native mode gives you realtime listeners, direct client SDK access, and security rules; Datastore mode exists for compatibility with legacy Cloud Datastore and is controlled by IAM only. New projects should pick Native mode unconditionally, even for backend-only access.
Security rules are the only perimeter for client access
If the browser talks to Firestore directly, rules are not defense in depth — they are the entire defense. Default-deny, then grant narrowly, and validate the shape of writes:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if false;
}
match /users/{userId} {
allow read: if request.auth != null;
allow write: if request.auth != null && request.auth.uid == userId;
}
match /posts/{postId} {
allow read: if true;
allow create: if request.auth != null
&& request.resource.data.authorId == request.auth.uid
&& request.resource.data.title is string
&& request.resource.data.title.size() <= 100;
allow update, delete: if request.auth != null
&& resource.data.authorId == request.auth.uid;
}
}
}
Two things people get wrong:
-
resource.datais the document before the write;request.resource.datais what the client is proposing. Ownership checks on update read the former; field validation reads the latter. - Rules are not filters. A rule that allows reading only your own documents does not make
collection('posts').get()return a filtered set — it makes the whole query fail. The query must be constrained to match the rule.
And note that the Admin SDK bypasses rules entirely. Any backend holding those credentials is fully trusted; give its service account roles/datastore.user, never roles/datastore.owner.
The checklist
- Count reads per screen render in the network log, then multiply by expected DAU × sessions.
- No
await get()inside a loop. Batch within, or denormalize. - Never
.get()a collection to call.size— usecount()or a maintained counter field. -
onSnapshotonly where realtime is the feature, and always with an unsubscribe. - Keep hot-updating fields out of widely-listened documents.
- Turn on persistent local cache.
- Set a budget alert before you need one.
Top comments (0)