Scaling Firestore: From Prototype to Production
Firestore is a fantastic database for rapid prototyping. Its flexible document model, real-time listeners, and serverless nature let you ship features in hours instead of weeks. But the same patterns that make prototyping effortless can quietly sabotage you at scale. This tutorial walks through the architectural, data-modeling, and operational shifts required to take a Firestore-backed application from a weekend hack to a production system serving millions of users.
Why Scaling Firestore Is Different
Unlike a traditional SQL database where you tune queries and add indexes, Firestore scaling is almost entirely about data modeling and access patterns. You cannot throw hardware at a bad Firestore schema. The two constraints that bite hardest are:
- The 1 MB document size limit — documents cannot grow unboundedly.
- The 1 write per second per document soft limit — hot documents become bottlenecks.
- Query limitations — no inequality filters on multiple fields, no full-text search, no aggregations.
- Read-based billing — a single query that scans a large collection can cost real money.
If you respect these constraints from day one, scaling is mostly painless. If you don't, no amount of infrastructure will save you.
1. Data Modeling for Scale
Subcollections vs. Root Collections
The most common prototype mistake is stuffing everything into a single document. Consider a chat app where each conversation is a document containing an array of messages:
// ❌ Prototype pattern — breaks at scale
const conversationRef = db.collection('conversations').doc(convId);
await conversationRef.set({
participants: [uid1, uid2],
messages: [
{ from: uid1, text: 'hi', ts: Date.now() },
// ... grows forever
]
}, { merge: true });
This works for 50 messages. At 5,000 messages you hit the 1 MB limit, every write re-sends the entire array, and every listener download is enormous. The production pattern uses subcollections:
// ✅ Production pattern — subcollection per conversation
const messagesRef = db
.collection('conversations')
.doc(convId)
.collection('messages');
await messagesRef.add({
from: uid1,
text: 'hi',
ts: Date.now()
});
Now each message is its own document, writes are independent, and you can paginate with cursors. The trade-off: you can no longer atomically update the conversation and all its messages in one write. You'll need to denormalize summary data onto the parent document.
Denormalization Is Not Optional
In SQL, you normalize and join at query time. In Firestore, joins are expensive and limited. The production approach is to duplicate data into the shape your reads need. For example, store a conversation's last message preview on the conversation document:
// On every new message, update the parent summary
const batch = db.batch();
const msgRef = db.collection('conversations')
.doc(convId)
.collection('messages')
.doc();
batch.set(msgRef, { from: uid1, text: 'hi', ts: Date.now() });
batch.set(db.collection('conversations').doc(convId), {
lastMessage: 'hi',
lastMessageAt: Date.now(),
lastMessageFrom: uid1
}, { merge: true });
await batch.commit();
This costs one extra write per message but lets you render an inbox list with a single query instead of N+1 reads.
2. Avoiding Hot Documents
The Counter Problem
Counters are the classic Firestore scaling trap. Incrementing a single document on every event caps you at roughly one update per second:
// ❌ Hot document — will fail under load
const counterRef = db.collection('counters').doc('pageViews');
await counterRef.update({
count: admin.firestore.FieldValue.increment(1)
});
The solution is distributed counters: shard the count across N documents and sum them on read.
// ✅ Distributed counter — write path
const SHARDS = 10;
async function incrementCounter(counterId) {
const shardId = Math.floor(Math.random() * SHARDS);
const shardRef = db.collection('counters')
.doc(counterId)
.collection('shards')
.doc(shardId.toString());
await shardRef.set(
{ count: admin.firestore.FieldValue.increment(1) },
{ merge: true }
);
}
// Read path — sum all shards
async function getCounter(counterId) {
const snap = await db.collection('counters')
.doc(counterId)
.collection('shards')
.get();
let total = 0;
snap.forEach(doc => { total += doc.data().count || 0; });
return total;
}
Ten shards give you roughly 10 writes per second. For higher throughput, increase the shard count. For reads that don't need to be exact, cache the summed value in a separate document and refresh it on a schedule.
Fan-out Writes with Batched Writes
When a single logical action touches many documents — like sending a notification to 400 followers — use batched writes. Each batch can contain up to 500 operations:
async function fanOutNotification(authorId, notification) {
const followersSnap = await db.collection('users')
.doc(authorId)
.collection('followers')
.get();
const batches = [];
let batch = db.batch();
let opCount = 0;
followersSnap.forEach(doc => {
const notifRef = db.collection('users')
.doc(doc.id)
.collection('notifications')
.doc();
batch.set(notifRef, notification);
opCount++;
if (opCount === 400) {
batches.push(batch.commit());
batch = db.batch();
opCount = 0;
}
});
if (opCount > 0) batches.push(batch.commit());
await Promise.all(batches);
}
For fan-outs larger than a few thousand, move the work to a Cloud Function triggered by a queue document, and process in chunks to avoid memory pressure.
3. Query Patterns That Scale
Pagination with Cursors
Never use limit with offset for pagination — Firestore charges for every skipped document. Use cursor-based pagination instead:
// First page
const firstPage = await db.collection('messages')
.doc(convId)
.collection('messages')
.orderBy('ts', 'desc')
.limit(20)
.get();
// Subsequent page — start after the last visible doc
const lastDoc = firstPage.docs[firstPage.docs.length - 1];
const nextPage = await db.collection('conversations')
.doc(convId)
.collection('messages')
.orderBy('ts', 'desc')
.startAfter(lastDoc)
.limit(20)
.get();
Composite Indexes
Multi-field equality and range queries require composite indexes. Firestore auto-suggests them in the console, but in production you should declare them explicitly in firestore.indexes.json and deploy via the CLI:
{
"indexes": [
{
"collectionGroup": "messages",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "conversationId", "order": "ASCENDING" },
{ "fieldPath": "ts", "order": "DESCENDING" }
]
}
]
}
Deploy with firebase deploy --only firestore:indexes. Treat index definitions as code — review them in PRs and remove unused ones, since each index adds write cost and storage.
Offloading Search and Aggregation
Firestore cannot do full-text search or count queries efficiently on large collections. The production pattern is to sync data to a complementary system:
- Algolia or Elastic for full-text search, kept in sync via Cloud Functions triggers.
- BigQuery for analytics, populated by a scheduled export.
- Scheduled Cloud Functions for periodic aggregations written back to summary documents.
// Cloud Function: keep Algolia in sync on write
exports.syncToAlgolia = functions.firestore
.document('products/{productId}')
.onWrite(async (change, ctx) => {
const index = algoliaClient.initIndex('products');
if (!change.after.exists) {
await index.deleteObject(ctx.params.productId);
return;
}
const data = change.after.data();
await index.saveObject({
objectID: ctx.params.productId,
name: data.name,
description: data.description,
tags: data.tags
});
});
4. Security Rules at Scale
Security rules are not just access control — they are a contract that documents your data model. A common production mistake is writing permissive rules that pass QA but leak data in the wild. Structure rules around resource paths and validate schema:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function isSignedIn() {
return request.auth != null;
}
function isOwner(uid) {
return isSignedIn() && request.auth.uid == uid;
}
match /users/{userId} {
allow read: if isOwner(userId);
allow create: if isOwner(userId)
&& request.resource.data.email is string
&& request.resource.data.createdAt is timestamp;
allow update: if isOwner(userId)
&& request.resource.data.email == resource.data.email;
}
match /conversations/{convId}/messages/{msgId} {
allow read: if isSignedIn()
&& request.auth.uid in get(
/databases/$(database)/documents/conversations/$(convId)
).data.participants;
allow create: if isSignedIn()
&& request.resource.data.from == request.auth.uid;
}
}
}
Two rules of thumb: never allow list on a collection without a bound on the query, and never trust client-supplied fields like from or createdAt without validation.
5. Cost Control
Firestore bills per read, per write, and per GB stored. At scale, reads dominate. The highest-leverage optimizations are:
- Avoid listener fan-out. A dashboard with 20 real-time listeners on large collections multiplies read costs. Prefer targeted queries with
whereclauses. - Use
select()projections when you only need a few fields — this reduces bandwidth but not document read count, so use it for performance, not cost. - Cache aggressively on the client. Persist documents locally and only re-fetch when stale.
- Archive cold data. Move old documents to Cloud Storage or BigQuery and delete them from Firestore to reduce storage costs.
// Project only needed fields for a list view
const snap = await db.collection('users')
.where('active', '==', true)
.select('displayName', 'avatarUrl')
.limit(50)
.get();
6. Observability and Operations
In production you need visibility into which queries are expensive and which rules are failing. Enable the following:
- Cloud Monitoring metrics for document reads, writes, and delete counts, with alerts on sudden spikes.
- Security Rules evaluation logs — denied requests often reveal bugs in client queries.
- Cloud Functions logs for trigger-based functions, with structured logging so you can trace a request across the pipeline.
// Structured logging in a Cloud Function
exports.onMessageCreate = functions.firestore
.document('conversations/{convId}/messages/{msgId}')
.onCreate(async (snap, ctx) => {
const data = snap.data();
console.log(JSON.stringify({
severity: 'INFO',
convId: ctx.params.convId,
msgId: ctx.params.msgId,
from: data.from,
ts: data.ts
}));
// ... downstream work
});
Best Practices Checklist
- Model data around read patterns, not write convenience.
- Use subcollections for any unbounded list (messages, events, logs).
- Distribute counters across shards; never increment a single hot document.
- Paginate with cursors, never with offset.
- Declare composite indexes in
firestore.indexes.jsonand review them in code review. - Offload full-text search and analytics to Algolia, Elastic, or BigQuery.
- Validate schema and ownership in security rules; never trust client fields.
- Batch fan-out writes in groups of 400 or fewer.
- Cache on the client and archive cold data to control costs.
- Instrument reads and writes with Cloud Monitoring and structured logs.
Conclusion
Scaling Firestore is less about infrastructure and more about discipline. The database itself handles sharding, replication, and availability automatically — your job is to design data models and access patterns that stay within its constraints. By moving from monolithic documents to subcollections, replacing hot counters with distributed shards, paginating with cursors, offloading search and aggregation to purpose-built systems, and treating security rules as a versioned contract, you can take a prototype that served ten users and grow it to serve millions without a rewrite. The earlier you adopt these patterns, the less painful the journey from prototype to production will be.