+1 (762) 572-1994

Firebase Data Connect or Firestore? A Decision Procedure

Firebase Data Connect is the first Firebase product that gives you a relational database with Firebase-shaped ergonomics: Cloud SQL for PostgreSQL underneath, a GraphQL schema on top, generated type-safe client SDKs, and Firebase Auth wired into the authorization layer. It arrived into a product line where the answer to "I need a join" had been "denormalize it" for a decade.

That makes it genuinely useful and genuinely easy to misuse. Teams reach for it either too eagerly — rewriting a working Firestore app — or not at all, and keep paying for fan-out writes that a GROUP BY would have handled. Here is the procedure we use on engagements.

What actually changes

Three things, and only three matter for the decision.

The query surface. Firestore has no joins, no aggregations beyond count(), sum(), and average(), no LIKE, no arbitrary ORDER BY across fields without a matching composite index. Data Connect is Postgres: joins, subqueries, window functions, pgvector for embeddings, whatever you can write. Queries are defined server-side in .gql files and deployed; the client calls the named operation, not arbitrary SQL.

The cost shape. Firestore bills per document read, write, and delete. You pay nothing when nobody is using the app. Data Connect sits on a Cloud SQL instance, which is a provisioned VM billed per hour whether or not it serves a request, plus storage and egress. A small always-on instance is a fixed monthly line on the invoice before the first query runs. Check the current Cloud SQL price sheet for your region and machine type — the number moves, the shape does not.

The realtime story. Firestore's onSnapshot is a push channel: the server tells the client when a document changes. Data Connect operations are request/response. If your UI depends on live updates, that dependency does not port; you poll, you refetch on mutation, or you keep that slice of data in Firestore.

The decision procedure

Run each dataset — not each app — through these questions in order.

1. Does the read path need a join or an aggregate that Firestore cannot express?

Not "would a join be nicer." Look for the workaround already in your codebase: a Cloud Function that fans a write out to six denormalized copies, a client that issues N+1 getDoc calls in a loop, a nightly job rebuilding a leaderboard. Those workarounds are the cost of Firestore's model, and they are measurable. An admin dashboard listing 50 orders with customer name, plan tier, and lifetime spend is 1 query plus 100 lookups in Firestore — 101 reads per page view. In Data Connect it is one operation against the instance you are already paying for.

2. Is the write path high-fan-out and latency-sensitive?

Firestore is excellent at "many clients writing small documents, everyone sees it immediately." Chat, presence, collaborative cursors, notification feeds, live order status. Keep that in Firestore. A relational instance with a connection pool is the wrong shape for 10,000 concurrent mobile writers, and you will meet connection limits before you meet a query limit.

3. Does the data have real referential structure that keeps breaking?

If you have written a Cloud Function whose only job is cleaning up orphaned documents after a delete, you have been hand-implementing ON DELETE CASCADE. That is a signal, not a mandate — but it is the kind of correctness bug that keeps recurring in Firestore models and simply cannot happen in Postgres.

4. What does an idle hour cost?

For a B2B app used by 400 people during business hours, an always-on instance is fine and probably cheaper than the read amplification you are paying today. For a consumer app with a long tail of dormant users and spiky traffic, Firestore's scale-to-zero billing is hard to beat. Do this arithmetic with your own numbers before anything else: current monthly Firestore reads for the dataset in question, versus the monthly instance price for the smallest machine that holds your working set.

Authorization works differently — read this part twice

This is where we see the most dangerous assumptions. Firestore security rules are evaluated per document, on every operation, against request.auth. Data Connect has no per-row rules engine of that kind. Authorization is declared per operation with the @auth directive, and anything finer than that is expressed inside the query itself.

query ListMyInvoices @auth(level: USER) {
  invoices(where: { ownerId: { eq_expr: "auth.uid" } }) {
    id
    amountCents
    issuedAt
  }
}

Two consequences:

  • @auth(level: PUBLIC) on a query that returns user data is a data breach, and nothing downstream will catch it for you. There is no second layer the way rules sit behind a client call. Audit every operation's level as deliberately as you audit a rules file.
  • The row filter lives in the query. eq_expr: "auth.uid" is server-side and the client cannot override it. Filtering in the client SDK instead, or accepting an ownerId argument from the caller, reintroduces exactly the bug that request.resource.data.ownerId == request.auth.uid exists to prevent in rules.

App Check applies to Data Connect too, and should be enforced there for the same reasons it is enforced on Firestore and Functions. If you are rolling it out, do it staged — unenforced first, watch the metrics, then enforce.

And test it. The operations are deployed artifacts, so they are testable in CI: run each one as an authenticated caller who should not see the rows, and assert an empty result or an error. That is the same denial-first discipline we apply to security rules, pointed at a different engine.

The hybrid we usually recommend

Almost nobody should move everything. The split that holds up:

DataWhereWhy
Chat, presence, live status, notification feedFirestorePush updates, high-fan-out writes, scale-to-zero
Orders, invoices, entitlements, org membershipData ConnectJoins, referential integrity, reporting queries
Analytics and dashboards over historical dataBigQueryNeither of the above is a warehouse
User profile basics read on every screenFirestoreOne cheap document read on a hot path

Note that identity stays in Firebase Auth regardless. Both sides see the same auth.uid, which is what makes the split workable — you are not running two user systems, you are running two storage engines behind one identity.

If you do move something

Incremental cutover, same as any other migration:

  1. Model the target schema in .gql and deploy it against a non-production Cloud SQL instance. The Data Connect emulator runs locally, so schema and operations are testable before anything is provisioned.
  2. Dual-write from a Cloud Function triggered on the Firestore collection. Both stores stay current; nothing reads from the new one yet.
  3. Backfill history, then run reads in parallel for a week — serve from Firestore, query Data Connect in the background, log mismatches. Ship nothing until the mismatch count is zero and you understand why it was ever nonzero.
  4. Flip reads behind a flag, per surface, starting with the lowest-traffic screen.
  5. Stop the dual-write only after the Firestore copy has been unread for a full billing cycle, and you have confirmed the drop in the read line on the invoice.

Step 5 is the one teams skip, and it is the only step that proves the migration paid for itself.

The short version

Data Connect is a good answer to "Firestore cannot express this query" and a bad answer to "Firestore feels old-fashioned." It trades per-operation billing for per-hour billing and trades a per-document rules engine for per-operation authorization. Both trades are fine when you make them on purpose, for a specific dataset, with the read counts in front of you.

If you want a second opinion on which of your collections belong on which side of that line, that is roughly what a Firebase audit produces.