Originally published on jahanzaibramzan.com.
Firebase is the fastest way I know to get a mobile app from idea to store. It's also very easy to build something that works beautifully at 1,000 users and falls over — or gets expensive — at 100,000. The difference is almost never Firebase itself. It's a few data-modeling and Cloud Functions decisions that are cheap to make early and painful to change later.
This is what I've learned keeping a Firebase-backed React Native app healthy as it grew from around 10,000 to more than 300,000 users.
Model for reads, not for tidiness
Firestore bills per document read, and every read is a network round trip on a phone. So the question for every screen is: how many documents does this screen need to open? If the answer is "one per item in the list," you have a problem that will get worse in proportion to your success.
The instinct from relational databases is to normalise: a users collection, a posts collection, a comments collection, and join them on the client. In Firestore, that turns a feed of 20 posts into 20 post reads plus 20 author reads plus 20 comment-count queries.
Instead, denormalise what the screen shows. A post document should carry the author's display name and avatar URL, the like count, and the comment count. Update those copies when the source changes — usually from a Cloud Function, so the client never has to.
// posts/{postId}
{
title: "…",
body: "…",
authorId: "u_123",
author: { name: "Ayesha", avatarUrl: "…" }, // copied, not joined
likeCount: 42,
commentCount: 7,
createdAt: Timestamp,
}
One read per list item, and the client stays simple.
Keep counters out of the client
Counting is the classic Firestore trap. collection("posts").where("authorId", "==", uid).get().size reads every document just to count them. At scale that's slow, expensive, and racy.
For anything users see constantly — likes, followers, streaks, leaderboard points — maintain a stored counter and update it server-side:
// Cloud Function: keep commentCount in sync
export const onCommentCreated = onDocumentCreated(
"posts/{postId}/comments/{commentId}",
async (event) => {
const postRef = db.doc(`posts/${event.params.postId}`);
await postRef.update({ commentCount: FieldValue.increment(1) });
}
);
For counters that many users hit at once (a viral post, a global leaderboard), a single document caps out at roughly one write per second. Use the distributed counter pattern — a handful of shard documents summed on read — or aggregate periodically with a scheduled function. For "count all documents" cases where the number just needs to be shown, count() aggregation queries are much cheaper than reading every document, though they still aren't free.
Precompute the expensive views
A weekly leaderboard that's recalculated every time someone opens the screen is a scaling problem waiting to happen. The pattern that works: compute it once, on a schedule, and write the result to a small document the app reads.
export const buildWeeklyLeaderboard = onSchedule("every 15 minutes", async () => {
const top = await db.collection("users")
.orderBy("weeklyPoints", "desc")
.limit(100)
.get();
await db.doc("leaderboards/weekly").set({
updatedAt: FieldValue.serverTimestamp(),
entries: top.docs.map((d) => ({
uid: d.id,
name: d.get("displayName"),
avatarUrl: d.get("avatarUrl"),
points: d.get("weeklyPoints"),
})),
});
});
Now every user opening the leaderboard costs one read, regardless of how many users you have. The same idea applies to "recommended for you", "trending", daily challenge content, and anything else that's shared across many users.
Design for offline from day one
Firestore's offline persistence is enabled by default on iOS and Android, and it's one of the best reasons to use Firebase for mobile. But it only helps if your data model plays along.
Keep the documents a user interacts with most — their own profile, progress, settings — small and self-contained, so they cache well and can be written offline without conflicts. Avoid patterns where a single user action must update several documents atomically from the client; move that into a Cloud Function triggered by one write.
In the React Native app, read from the cache first and let the server update arrive:
const unsubscribe = onSnapshot(
doc(db, "users", uid, "progress", "current"),
{ includeMetadataChanges: true },
(snap) => {
setProgress(snap.data());
setIsFromCache(snap.metadata.fromCache);
}
);
Users get an instant screen; you get fewer "the app is slow" reviews.
Write security rules that scale with you
Rules are evaluated on every request, and rules that read other documents (get() / exists()) count as reads. A rule that checks a user's role by fetching their profile document on every read effectively doubles your read bill.
Two habits keep this in check. First, put authorisation data on the auth token as custom claims, set from a Cloud Function, so rules can check request.auth.token.role with no extra read:
match /admin/{doc=**} {
allow read, write: if request.auth.token.role == "admin";
}
Second, structure paths so ownership is obvious from the path itself:
match /users/{uid}/{document=**} {
allow read, write: if request.auth.uid == uid;
}
Test rules with the emulator suite before deploying. A rules bug at scale is either a data leak or a wave of permission-denied crashes, and both show up in reviews.
Keep Cloud Functions fast and cheap
Functions scale automatically, but each cold start adds latency and every instance costs money. The things that matter most:
- Pin a region close to your users and your Firestore location. Cross-region calls add hundreds of milliseconds.
- Set
minInstances: 1on the handful of functions that sit in the user's critical path (login, first-screen data) so they don't cold start. - Keep functions small and single-purpose. A 40 MB bundle that imports the entire Admin SDK plus half of npm starts slower than one that imports what it uses.
- Use
onCallfunctions for client-to-server RPC rather than raw HTTPS; you get auth context and typed errors for free. - Add idempotency to triggers. Firestore triggers can fire more than once; an
increment(1)that runs twice is a bug you'll only notice in the data.
Watch the Functions dashboard for execution time and error rate per function; the slow one is usually obvious.
Control cost before it controls you
A Firebase bill that surprises you is a data-model bill. Once denormalisation, stored counters, and precomputed views are in place, the remaining levers are:
-
Pagination everywhere. Never load a collection without
limit(). UsestartAfter()with a cursor, not offset. - Bundle static content. Reference data that rarely changes (vocabulary lists, level definitions, category trees) can be shipped as a Firestore data bundle or plain JSON in Cloud Storage with a version stamp, rather than read per user.
- Storage image variants. Generate resized images at upload with the Resize Images extension so phones download thumbnails, not originals.
- Budget alerts. Set a Google Cloud budget with alerts at 50/80/100%. It costs nothing and it's the only way to catch a runaway loop before month-end.
Plan the migrations you'll need
You will change your data model. Firestore has no schema migrations, so plan for it:
- Add a
schemaVersionfield to documents you expect to evolve. - Write client code that tolerates missing fields rather than crashing on old documents.
- For large backfills, run a Cloud Function or a script with the Admin SDK in batches of 500 writes, with a checkpoint so you can resume.
The app I've spent the most time on has been through several of these; none were fun, but all were manageable because the versions were explicit and the client was forgiving.
The short version
Model each screen for one or two reads. Keep counters and aggregates in Cloud Functions. Precompute anything shared across users. Design documents to cache and write offline. Keep rules read-free. Paginate. Set budget alerts.
Do those things while the app is small and Firebase will carry you a very long way. If your app is already past that point and the bill or the latency is climbing, that's the kind of audit I do — see Services or get in touch.
Top comments (0)