I spent six years building Made Live on Firebase. The website platform I'm building now, Giant Context, runs on Postgres, and that decision was made before the repository existed. This is the reasoning, including the part where Firebase is genuinely better, because that part is what you have to rebuild.
Firebase gets plenty right, and the internet is oversupplied with people pretending it gets nothing right. Firestore is dirt cheap at small scale and it scales without ceremony. And it is reactive out of the box. A client subscribes to a query, any write anywhere shows up in that subscription, and entire categories of UI plumbing simply do not exist in your codebase. Teams do not pick Firebase because they love JSON trees. They pick it because onSnapshot makes the hardest part of a modern UI free.
Both of those are real, and neither one is why you leave.
You leave because of schema changes.
A schema change in Firestore is a running mechanism. It is not written down anywhere, it cannot be traced after the fact, and it cannot be reversed. You deploy code that writes the new structure, and from that moment the change is simply happening, document by document, for as long as users touch documents. There is no record that it started, no way to ask how far it has gotten, and no way to back out. After six years of Made Live, the documents spanned every era of that history simultaneously, and every read defended itself against three generations of shape, because nothing anywhere could say when generation two stopped being written.
A relational database makes you say what your data is, and makes you say it again, in writing, every time the answer changes. On Giant Context every schema change is a migration script, the scripts run in order, and the migration table records what ran and when. The directory, three weeks into the project, is already a build log:
Migration scripts, in execution order
packages/api/migrations/ 1734800000000_initial-schema.sql 1734800000001_seed-demo-data.sql 1734886800000_rbac-tables.sql 1734900000000_add-firebase-uid.sql 1734950000000_files-table.sql 1735000000000_multi-tenant-schema.sql 1735010000000_drop-users-role.sql 1735020000000_remove-wildcard-permission.sql ... 77 scripts and countingmigration scripts, and counting
Every structural decision the system has made, in execution order, reviewable in any diff, reversible from any point. The fourth file is the old world getting a column in the new one, because identity stayed on Firebase. Here it is, whole:
1734900000000_add-firebase-uid.sql
-- Add firebase_uid column to users table-- Links Firebase Authentication to internal user records
-- Up MigrationALTER TABLE users ADD COLUMN firebase_uid VARCHAR(128) UNIQUE;CREATE INDEX idx_users_firebase_uid ON users(firebase_uid) WHERE firebase_uid IS NOT NULL;
---- Down MigrationDROP INDEX IF EXISTS idx_users_firebase_uid;ALTER TABLE users DROP COLUMN IF EXISTS firebase_uid;The bottom half is the part the document store never offered. Every one of the 77 scripts carries its own down migration. The Firestore version of this change was a deploy you couldn't take back. This version is eight lines, and the last two are how you back out.
The document store lets you skip declaring your schema's history, and then your schema has a history whether you declared it or not. It's just stored as guesswork instead of scripts.
Leaving Firebase costs you the reactive layer, and the reactive layer was the point. Postgres will not push a changed row into your UI. If you migrate and skip this part, every screen needs a refresh button, and the product feels ten years older.
They pick it because onSnapshot makes the hardest part of a modern UI free.
So the reactive layer got rebuilt on Postgres, and the recipe is small enough to describe completely. Every mutation in the API declares a target, the name of the data domain it touched. Committing a write emits an event over a WebSocket to connected clients, and the event is an instruction rather than an announcement. It tells the frontend what to do, which in most cases is rehydrate a surface:
A realtime notification
[api] Received realtime notification {"event":{"action":"refetch","target":"users", "payload":{"id":"…","operation":"INSERT"}}}On the client, the query cache is organized by those same target names, so handling an event is one call. Invalidate the keys for that target, and the affected queries refetch. Screens showing that data update. Screens that aren't showing it do nothing. The surface is connected to the query, the query to the cache, the cache keys to the API schema. One tight chain.
A write commits
action, target, payload
Pushed to clients
Cache keys for the target
Stale queries re-render
A write commits
action, target, payload
Pushed to clients
Cache keys for the target
Stale queries re-render
The part that keeps this honest over time is that neither end of the loop is hand-maintained. The client's query hooks, their cache keys, and their invalidation groups are generated from the API's schemas, the same generation step that produces the API client and the form validation. A mutation cannot drift out of agreement with the cache keys it invalidates, because both sides are materialized from one declaration. The event contract itself, action, target, payload, and what a surface does when one arrives, is a pattern worth its own write-up.
I had already built this once, on a different database, at a different company.
Made Live ran on Firestore, where the loop comes free. My previous company ran on MongoDB Atlas, where it does not, and the answer there was Atlas Triggers. A write fires a trigger, the trigger notifies the backend, the backend pushes over a WebSocket, and the client rehydrates. Giant Context runs on Postgres, and the answer is the same shape with the trigger moved into the write path:
One pattern, two engines
MongoDB Atlas write → Atlas Trigger → backend → WebSocket → rehydratePostgres write → table event → WebSocket → handler → rehydrateTwo databases, two companies, one pattern. The database supplies the change signal. Everything downstream of the signal is yours, and it ports. Once you have built it on one engine, the second build is mostly remembering. The reactive subscription was never Firebase's technology moat. It is a small pattern that Firebase happens to monetize better than anyone.
The receipts on how long it took sit in the commit log. The migration tooling landed on December 21st (bb861a3ce). Before that date there was no database to be reactive about. Four days later, on Christmas, the realtime work was already down to edge cases, and the commit that proves it is a fix whose note says exactly where the system stood:
2025-12-25 · 6e07b76c0
fix(portal): reconnect websocket when auth state changes
WebSocket connections were connecting anonymously before Firebase auth initialized, causing user-targeted messages (like locale sync) to fail. Now the WebSocket reconnects when user auth state changes, ensuring authenticated connections receive targeted broadcasts.You do not write that note on Christmas Day unless the websocket layer has been live for days. From no database to a reactive one was a matter of days, not the quarter the free tier wants you to fear. Keeping it correct costs nothing, because the generation step keeps both ends of the loop in agreement.
What comes back in exchange is everything the document store withheld. Joins, transactions, constraints, a query planner, and the migration directory.
One more decision rode along from day one. Embeddings live in the same database, via pgvector, as columns on the rows they describe. An AI-heavy product accumulates embeddings the way any product accumulates rows, and the default industry answer is a second, dedicated vector store. The cost of that answer is a synchronization problem you own forever. Every embedded row now exists in two systems with two lifecycles and two failure modes. In one database, semantic search is a WHERE clause away from the relational facts, in the same transaction, the same backup, the same access control. At the scale of a young product there is no performance argument that pays for the second system.
Leaving Firebase did not mean leaving Google. Firebase is a Google product, and Giant Context runs on Google Cloud end to end. The Postgres in this article is Cloud SQL, the file storage is Cloud Storage, and the models are Gemini. What I left was Firestore.
Choosing Google again was its own decision with its own reasoning, and it deserves its own article. The short version is that two years at my previous company running a full production stack on Azure taught me what depth on one cloud is worth, I wanted that depth on Google's side this time, and keeping every service under one roof shortens every path, from billing to IAM to the model APIs the product is built around.
And one piece of Firebase itself survived the move. Identity Platform still handles auth. That is what add-firebase-uid.sql is doing in the listing above. The new schema keeps a column pointing at the old identity provider, on purpose, forever. Auth was never the problem. It is the part of Firebase with boring, well-defined edges, and ripping it out would have been ideology.
Choose the document store when the product is a guess, when the schema is young, iteration speed is everything, reactivity is load-bearing, and nobody will care about the data's history because most of it won't survive contact with users. Prototypes earn Firebase.
Choose relational the moment the schema's history becomes an asset, which is roughly the moment the product is real. A migration directory is institutional memory that survives every rewrite around it, and it can back out of anything it did. The running, untraceable, irreversible schema change is the real bill for the free reactivity, and it comes due years later, for whoever is still there.
And price the reactivity gap at what the commit log says it costs. Days of plumbing, built once, if your cache keys and your API schemas come from the same source of truth.
If you're interested in my work or Giant Context, contact me!