Mxo.

Back to Blog
Firebase
Database Design
Architecture
Multi-tenant SaaS

Firestore Architecture For Scaling Read-Intensive Multi-Tenant Software

The math to be considered when building a firestore based business. And a simulator you can use for planning your projects

Mxo MasukuApril 25, 202610 min read
Firestore Architecture For Scaling Read-Intensive Multi-Tenant Software

When you research how to build a multi-tenant SaaS on Firestore, most articles stop at one idea: put a tenantId field on every document. That's true, but it's the easy half. The more tricky question is how do you arrange your collections so that reads, writes, and costs stay within budget as the business scales.

Background

I was afraid of creating a system that was more trouble and expensive than it was worth.

I built a multi-tenant SaaS Trogern for managing informal taxi businesses in Zimbabwe. I was the first tenant. The industry here is so volatile and unpredictable that instant record retrieval and logging is key for business operations. In the process I got obsessed with designing a database that wouldn't generate reads and writes which I couldn't manage.

For most people, this is not a problem you need to solve today. Firestore is dirt cheap. The free tier gives you 50,000 reads and 20,000 writes per day.

The two axes of scale on Firestore

Multi-tenant load doesn't scale along one line, it scales along two. Deep and Wide.

Deep scale. One tenant with a lot of data. This can be something like one taxi company, 100 vehicles, 100 drivers, 20,000 income logs per quarter. The stress falls on one partition.

Wide scale. Many tenants with small amounts of data. 1,000 companies, each with 10 vehicles, all opening their dashboards at 9am Monday morning. The stress falls on global indexes, listeners, and cold-start reads.

Deep scaling breaks different things than wide scaling. The same data model can be great at one and awful at the other. For this reason you will find that most documentation, including Firebase official docs, do not recommend a one-size-fits all best practice for Firestore database design.

I'm using deep and wide for the rest of the article because vertical and horizontal are already taken by infrastructure vocabulary and they mean roughly the opposite of what you'd guess here.

What flat collections look like

Firestore supports subcollections — collections nested inside a document. For a fleet app you could do:

companies/{companyId}/
  vehicles/{vehicleId}/
    incomeLogs/{logId}

A flat layout goes the other way. One top-level collection per business entity.

companies/{companyId}
vehicles/{vehicleId}       // field: companyId
drivers/{driverId}         // field: companyId
income/{logId}             // fields: companyId, vehicleId, driverId

Each document carries companyId as a partition field or shared key. The document id is the primary key, usually auto-generated or a natural one like a plate number. The document also carries the other foreign keys it'll be queried against — vehicleId, driverId . This is to ensure that one query can pull income for a specific driver in a specific vehicle at a specific company.

The case for flat collections

Note: Firestore charges per document read, not per path depth. A nested lookup for one vehicle's income is one query, and it's just as cheap as a flat one.

The real reasons to go flat in a multi-tenant app are these:

  1. Cross-tenant admin queries. When you have a multi-tenant system, you become a platform manager and you will need to read across tenants. In a nested layout that requires a collection group query.. They are a Firestore query that operates across every subcollection of the same name. Collection groups work, but they demand more composite indexes and security-rule semantics. I wanted to avoid them.

  2. Simpler security rules. All you have to do when checking a company out is "match on the companyId field" which covers every collection. A nested layout makes you write path-matching rules that are more complex and easier to get wrong.

  3. Listener scoping. Now we look at onSnapshot, Firestore's real-time listener. It is a live socket that bills one read per doc that flows through it. On a flat collection that's one query: collection("income").where("companyId", "==", X).where("driverId", "==", Y).onSnapshot(...). One socket, one cleanup, identical shape no matter what you're watching. On subcollections the path is the scope. Watching one driver across 100 vehicles means either a collection group query (new index, new rule path, you've given back the benefit you went nested for) or 100 parallel listeners merged client-side. A real dashboard opens four or five of these and flat collections keep every one uniform. Nested collections however, give you a different path depth per concern, plus deduplication logic and 100× the cleanup callbacks. That's where the bill blows up. A single leaked listener in flat collections is one socket pulling extra reads, but a leaked listener over many subcollections could be dozens of sockets which all silent until the bill comes.

  4. Aggregation queries. count, sum, and avg are priced per 1,000 index entries (1 read per 1,000). They run cleanly on a flat collection. They work on subcollections too, but you run them per parent. This is fine for one tenant, painful for a platform-wide report.

  5. Data migration and correction. You can fix a bug like a wrong driverName on a week of income logs or a missing field across half the catalog with one query where("companyId", "==", X).where("cashDate", ">=", date) in a flat system. In a nested layout that same fix is a recursive walk down every company → vehicle → income path. This happens way more often than the architecture diagrams imply.

The one-query demonstration

With a flat layout, pulling income for a driver in a vehicle for a company is one call:

db.collection("income")
  .where("companyId", "==", "trogern-01")
  .where("vehicleId", "==", "ADB-121-GP")
  .where("driverId", "==", "DRV-8421")
  .orderBy("cashDate", "desc")
  .limit(50)
  .get();

Fine. That's cheap for one request. Now 1,000 tenants do it at the same time. Now each of those 1,000 tenants has a dashboard that runs three queries like this on page load and opens a real-time listener on notifications. Now half of them have 100 vehicles each, not 10.

That's when things get interesting.

Two ways to read: per-log vs rollup

Once a flat layout is in place, the next decision is how much work each dashboard load does. There are two patterns. The simulator below toggles between them, so it's worth defining them now.

Per-log. The obvious one. Every dashboard load runs a query against income and reads back every raw log in the window. 90-day quarterly view, 200 logs/day → 18,000 reads per load, per user. Simple, always correct, no extra moving parts.

Daily rollup. Every write to income also writes (or increments) a document in dailyRollups/{companyId}_{date} with the summed amount and a count. The dashboard reads 90 of those instead of thousands of raw logs. A Cloud Function fans the writes.

Aside: Cloud Functions aren't free either. Every income log triggers one invocation, and 2nd-gen functions bill at $0.40 per million invocations past the 2M free tier, plus compute time (GB-seconds and GHz-seconds) and any egress. For an app which does 200 logs/day across a big tenant, or 20/day across 1,000 small ones. That's roughly 6M invocations/month at the top end, around $1.60 in invocation fees plus a few dollars of compute. The rollup still wins by orders of magnitude on the read side, but the trade isn't quite "two docs written for free." Watch the function bill if your write volume is high — it's the next thing that bites once you've fixed reads.

dailyRollups/
  trogern-01_2026-04-25
    { companyId, date, totalIncome, totalExpense, logCount, ... }

You pay a little more on the write path (two docs per income log instead of one) and you save 20× to 200× on reads. For a read-heavy dashboard app, that trade is one-sided.

The downside: any historical correction requires recomputing the rollup. That's a Cloud Function with a retry queue, not a one-liner. It's the kind of debt you have to decide to take.

The simulator

I built this initially with my defaults, but every input is editable. Move the tenant slider, flip the architecture, watch the cost.

Interactive
Open the Firestore Cost Simulator →
Plug in your tenant profile and compare per-log vs rollup architecture costs.

The math is: each dashboard load fans out into a handful of reads. There is one big query for the income window, plus the surrounding lists, notifications and security-rule lookups. Multiplied by users, by loads per day, by 30 days, by tenants. Priced at $0.06 per 100K reads.

A dashboard load fans out roughly like this (numbers lifted from my actual controller code):

WhatReads per load (per-log)
Income, 90-day period statslogsPerDay × 90
Drivers listvehicles
Vehicles listvehicles
Active target1
Notifications initial fetch50
Security-rule get(/users/{uid}) per protected query~6

For a small tenant (10 vehicles, 20 logs/day) the per-log total is 1,877 reads per load. For a big tenant (100 vehicles, 200 logs/day) it's 18,257. The income window dominates — everything else is rounding error.

Here's the underlying function the widget runs, in about 25 lines:

type Tenant = { users: number; vehicles: number; logsPerDay: number; loadsPerUserPerDay: number };
type Arch = "per-log" | "rollup";
 
const READ_PRICE_PER_100K = 0.06; // USD, North America
 
function readsPerLoad(t: Tenant, arch: Arch): number {
  // Rollup stores one doc per company per day, so 90 docs for a quarterly dashboard.
  const income = arch === "per-log" ? t.logsPerDay * 90 : 90;
  const lists  = t.vehicles * 2;            // drivers list + vehicles list
  const misc   = 1 + 50 + 6;                // target + notifications + rule reads
  return income + lists + misc;
}
 
function monthlyUSD(tenants: number, t: Tenant, arch: Arch): number {
  const loadsPerDay = t.users * t.loadsPerUserPerDay;
  const monthlyReads = readsPerLoad(t, arch) * loadsPerDay * 30 * tenants;
  return (monthlyReads / 100_000) * READ_PRICE_PER_100K;
}

The numbers

Wide scale (all small tenants):

TenantsPer-log $/monthWith daily rollups
1$1.01$0.09
100$101$9
1,000$1,013$90
10,000$10,135$902

Deep scale: One big tenant (100 vehicles, one company): per-log = $24.65/month, rollup = $0.47/month.

Two things fall out.

  1. The per-log model's bill crosses $1,000/month somewhere around 1,000 small tenants. That's fine if you're charging $30/month per seat. That's catastrophic if you're running freemium.
  2. The per-log model costs more for one big tenant than for a hundred small ones. Deep scale punishes you harder than wide scale, because the income-log window grows linearly with the fleet while everything else stays flat.

Reality Checks

  • Under ~200 small tenants, or your first big tenant: stay per-log. The complexity of rollups isn't worth the $20–$100/month you'd save. Spend that time on the product.
  • Planning for 1,000+ tenants, or a first tenant with 50+ vehicles: build the rollup pattern on day one. Migrating from per-log to rollup once you have live data is painful. It is much cheaper to set up the Cloud Function up front and write both documents from the first income log.
  • Somewhere in the middle: watch your bill. Firestore bills daily, the spike shows up fast once it starts.
Share this article

Thoughts on this?

I'd love to hear from you — whether it's a question, a counterpoint, or something this sparked for you.

Follow Systems for Humans

I run a newsletter called Systems for Humans, where I publish essays, experiments, and projects on building software and systems that help humans act with clarity, discipline, and autonomy. Get updates when something new drops.