← Back to DevBytes

Scaling Firestore: From Prototype to Production

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:

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:

// 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:

// 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:

// 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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles