Skip to main content
Engineering

Firebase at Real Scale: Firestore Data Modeling, Cost Control, and When to Move Workloads Off It

Practical Firestore modeling, indexing, and cost patterns from production SvelteKit + Firebase platforms, including the points where we stopped using Firestore.

2026-08-02 · By Filip Lauc

Why Firestore modeling decisions are really cost decisions

In Firestore you pay per document read, write, and delete, not per query complexity. That means your data model is your pricing model. A schema that answers a screen with one query costs a fraction of one that fetches a list and then hydrates each row with a follow-up read.

This flips the instinct most developers bring from relational databases. In Postgres, normalising aggressively is usually correct because joins are cheap and storage discipline pays off. In Firestore there are no joins, so every normalisation boundary you create becomes either a client-side fan-out of reads or a duplicated field you have to keep in sync. Neither is free, and the choice between them should be made deliberately per access pattern rather than globally.

The practical method we use is to start from screens, not entities. For each view in the product, write down the exact query it needs, the fields it displays, and how often it loads. A dashboard that 500 users open every morning and that triggers 40 reads per load is a very different engineering problem from an admin export that runs twice a month, even if both touch the same underlying data.

Collection structure, denormalisation, and the patterns that hold up

The structures that survive in production are shallow top-level collections keyed by tenant or owner, subcollections for unbounded child data, and deliberately denormalised summary documents for anything a list view renders. Deep nesting and cross-collection lookups are what turn a fast prototype into a slow, expensive app.

A few specific patterns come up in almost every platform we build. Product listings store a small projection of the fields the grid needs, so a category page is one query against one collection rather than a query plus N document fetches. Counters and rollups live in a dedicated summary document per parent, updated by a Cloud Function on write, because Firestore aggregation over large collections is either unavailable or costs a read per matching document. Historical or append-only data such as order events, lab results, or audit logs goes into subcollections so the parent document stays small and cheap to read on every page load.

Duplication needs an owner. Every denormalised field should have exactly one writer, ideally a single triggered function, and the source of truth should be obvious from the schema. When two client paths and a function all update the same copied name field, drift is guaranteed and debugging it means reconciling three code paths under load.

  • Model per screen: one query per view is the target, N+1 hydration is the failure mode.
  • Keep frequently-read documents small; push large or growing arrays into subcollections.
  • Maintain counts and totals in summary documents written by triggers, not computed by scanning.
  • Give every duplicated field a single writer so sync logic lives in one place.
  • Prefer composite fields (for example status_createdAt) over multiple filter combinations that each need their own index.

Indexes, security rules, and the query shapes they force

Firestore composite indexes and security rules are both part of the query surface, not afterthoughts. Indexes determine which filter and sort combinations are even possible, while rules determine which documents a client may request. Designing them together prevents the common trap of a model that reads well but cannot be queried safely from the client.

On indexes, the discipline is to keep the set small and intentional. Every composite index costs write throughput and storage, and an index sprawl of forty entries is usually a sign that filtering logic was pushed to the client incrementally. When a screen needs several optional filters, a single precomputed field that encodes the combination is often cheaper than a matrix of indexes. Array-contains and inequality constraints have hard limits, and hitting them is a signal to reshape the document rather than to add another index.

On rules, the pattern we rely on is ownership fields embedded in the document plus role claims on the auth token. A document carries the organisation or user ID it belongs to, rules compare that against request.auth, and roles that change rarely live in custom claims so they cost nothing to verify. Rules that perform get() lookups against other documents are readable but they bill as reads and add latency to every operation, so we reserve them for low-volume admin writes rather than hot read paths. Anything that requires genuinely complex authorisation logic moves behind a server endpoint or callable function where the rules can be simple deny-all.

Controlling Cloud Functions cost, cold starts, and write amplification

Cloud Functions costs and latency at scale come from three places: cold starts on infrequently used endpoints, trigger chains where one write fans out into many more, and functions doing work that belongs in a batch job. Each has a concrete fix, and none of them require leaving Firebase.

Cold starts are mostly a dependency problem. Functions that import a large shared module, initialise clients at module scope, or bundle the entire admin SDK when they need one service start measurably slower. Splitting deployment by responsibility, importing lazily inside the handler, and setting a minimum instance count only on the handful of user-facing endpoints where latency is visible covers most of the pain. Everything asynchronous, such as email sending, PDF generation, or index rebuilding, can tolerate a cold start and should not be paying for warm instances.

Write amplification is the subtler risk. A trigger that updates a parent summary, which itself has a trigger that updates a tenant rollup, which writes an audit entry, turns one user action into four billable writes plus three function invocations. We keep trigger depth to one level wherever possible, batch rollup updates on a schedule when second-level freshness is not required, and make triggers idempotent so retries do not double-count. Idempotency matters more than it sounds: Firestore triggers can fire more than once, and a counter that increments on every delivery will drift permanently.

When we moved workloads off Firestore, and where they went

We move a workload off Firestore when it needs multi-field ad hoc querying, aggregation over large ranges, relational integrity across many entities, or full-text search. Those are structural limits, not tuning problems. Firestore stays as the operational store for the app, and the specific workload moves to a tool built for it.

The clearest triggers we have hit in production: business intelligence dashboards that need arbitrary group-by and date-range aggregation, which we serve from a warehouse fed by scheduled exports rather than by reading collections; reporting and reconciliation that must be internally consistent across orders, inventory, and payments, which belongs in a relational database with real transactions and constraints; and search across product or content catalogues, where a dedicated search index handles typo tolerance, faceting, and ranking that Firestore simply does not offer. Heavy document generation and long-running batch processing also leave, going to a container-based runtime with no execution time ceiling.

The migration pattern that keeps risk low is additive rather than a cutover. Keep Firestore as the write path, stream or export the relevant collections into the new store, build the read path against the new store, verify the numbers match over a period of real usage, then retire the Firestore-based queries. This avoids a big-bang migration and it keeps the realtime, offline-capable behaviour that made Firebase the right choice for the app layer in the first place. Firebase not being the answer for one workload is very different from Firebase being the wrong platform.

  • Analytics and BI aggregation: scheduled export to a warehouse, queried by the dashboard.
  • Cross-entity reporting and reconciliation: relational database with transactions and constraints.
  • Full-text and faceted search: dedicated search index synced by triggers.
  • Long-running jobs and heavy exports: container runtime instead of a function with a timeout.
  • Realtime app state, auth, and per-user reads: keep in Firestore, where it is genuinely strong.

A checklist for keeping a Firebase platform predictable

Predictability comes from measuring reads per screen, capping unbounded queries, and reviewing the cost of your top three most-loaded views on a regular cadence. Most Firebase bill surprises are not caused by growth, they are caused by one screen that quietly performs dozens of reads per load.

Concretely, we instrument read counts in development so a regression in query shape is visible before deploy, we require pagination limits on every list query rather than trusting collections to stay small, and we cache reference data such as categories, settings, and taxonomy on the client or at the edge because it changes rarely and is read constantly. Server-side rendering helps here too: fetching a page's data once on the server and sending HTML avoids each client repeating the same reads, and it makes the cost of a page load a property of the route rather than of the visitor's session.

The last piece is knowing your exit doors before you need them. If a schema is designed so that its collections can be exported cleanly and its documents carry stable IDs and timestamps, moving a workload later is a week of work. If the schema encodes business logic in nested maps and relies on client-side joins, the same move becomes a rewrite. Designing for a possible exit is cheap insurance, and in our experience it also produces a cleaner Firestore model in the meantime.

Key Takeaways

  • In Firestore, the data model is the cost model: design collections around the queries each screen needs, not around normalised entities.
  • Denormalise deliberately and give every duplicated field a single writer, usually one trigger, to prevent drift.
  • Keep composite indexes intentional and security rules cheap; avoid get() lookups in rules on hot read paths.
  • Cut Cloud Functions cost by limiting trigger depth, making triggers idempotent, and reserving warm instances for latency-sensitive endpoints only.
  • Move analytics, cross-entity reporting, and full-text search off Firestore additively while keeping it as the realtime app store.

For a complete, working reference of the stack these patterns come from, see our open source Agrimatco repository, the full source of a production SvelteKit and Firebase site including functions, rules, and content management.

Frequently Asked Questions

Is Firestore actually cheaper than a managed Postgres database?

It depends entirely on read patterns. Firestore is usually cheaper for apps with modest, predictable per-user reads and no operational overhead, because you pay nothing when idle and never manage a server. A read-heavy dashboard or reporting workload can cost more in Firestore than a small managed Postgres instance, since Postgres charges for capacity rather than per row returned.

How many documents can a Firestore collection hold before it becomes a problem?

Collection size itself is not the limit; Firestore handles very large collections fine because queries scale with the result set, not the collection. Problems appear when you need aggregation across the collection, arbitrary multi-field filtering, or per-document write rates above roughly one write per second on a single document. Those are the signals to reshape the model or move the workload.

Should I use Firestore or the Realtime Database for a new project?

Firestore is the default choice for almost all new projects because of its query capabilities, composite indexes, multi-region replication, and stronger security rule model. Realtime Database still wins for very high-frequency, small-payload state such as presence indicators or live cursor positions, where its per-connection pricing and low latency are advantageous.

Can you run a multi-tenant SaaS on Firebase safely?

Yes, provided tenancy is enforced in the data model rather than only in application code. Store the tenant ID on every document, filter on it in every query, and verify it in security rules against a custom claim on the auth token. Sensitive cross-tenant operations should go through server endpoints with rules set to deny direct client access.

What is the fastest way to find out where my Firebase bill is going?

Start with the usage breakdown in the Firebase console to separate reads, writes, deletes, function invocations, and egress, then correlate spikes with releases. From there, instrument read counts per route in development and check your highest-traffic screens first, because a single N+1 query pattern on a popular view usually accounts for most unexplained cost.

Filip Lauc

Written by

Filip Lauc

CEO, Jaspero

Filip Lauc is the CEO of Jaspero, a software development agency based in Osijek, Croatia. A full-stack JavaScript developer with over a decade of experience across Angular, Svelte, and Node.js, he leads Jaspero's work as a long-term embedded engineering partner for clients like GlycanAge, where his team has served as the dedicated engineering team for six years.

Let's Build Together

Your vision,
our expertise.

From AI integration to full-stack development, we turn ambitious ideas into products that perform.