Engineering

Why we left Firebase for Postgres.

By Nuhman Shibli MC18 Mar 20269 min read
We ran Firebase in production for nearly two years before moving the data layer to Postgres. The decision wasn't fast, and it wasn't because Firebase is bad. It was because, eventually, the boundaries of Firestore stopped fitting the shape of the product. What worked. Firestore is fast to start. The auth, storage, and real-time channels are integrated. For a product that's still finding its shape — schema changing weekly, no real concept of "joins" yet — it's an honest answer. We had functional, signed-in, multi-tenant apps in production in a week. The free tier got us through pre-launch. The pricing didn't bite until we were past the inflection point where revenue covered it. What didn't. The break point, for us, was access control. Firestore security rules look declarative until you actually need them to compose. By the time we had per-team, per-role, per-tenant rules for a half-dozen collections, our rules file was a 900-line YAML-adjacent thing nobody on the team wanted to touch. Every new feature came with a 20-minute argument about whether the rule we were about to add silently widened an existing rule. We added tests, then added tests for the tests. We were writing more rule logic than feature logic. The second issue was queryability. Firestore is a key-value store dressed as a document store. The moment we needed to answer "give me posts where author is in this team and tag is X and status is published and orderBy createdAt" — a sentence that takes Postgres ten characters of where-clause — we were either fanning out to multiple queries client-side or maintaining a compound index that broke every time we added a filter. The product wasn't doing anything exotic. It was doing what every product eventually does: ask the database two questions at once. The third issue, and this one's quieter, was that we didn't trust the failure modes. Firestore has eventual-consistency edges that don't surface in development. They surface when a user is on a flaky train wifi, the write retries, the read happens against a stale region, and the user sees state that shouldn't exist. We had three of these we never fully root-caused before we moved. The migration. We ran both systems in parallel for three weeks. Writes went to both. Reads stayed on Firestore. Every read in production was also compared against the Postgres equivalent, asynchronously, and the diff was logged. After two weeks the diff rate was under one in ten thousand, and the ten-thousand were schema gaps we'd already classified. Then we flipped reads. A week later we turned off Firestore writes. The migration script was about 800 lines of TypeScript. It ran in 14 minutes against production data. We rehearsed it four times against full snapshots in staging. Two of those rehearsals failed, which is the only reason the production run worked. What we picked up with Postgres. Row-level security, where the access rule lives next to the schema and the database refuses to return rows the user shouldn't see. Real foreign keys, which catch dangling references at write time, not at the next 3 a.m. bug report. A query planner we can read. Indexes we can add, drop, and reason about. Migrations we can version-control and run forward and backward. What we lost. The realtime layer, briefly. We replaced it with a Postgres LISTEN/NOTIFY pipe surfaced through a thin websocket layer. It took a week to write and is, honestly, less polished than Firestore's. We're fine with that tradeoff. We use realtime in maybe two places now. We didn't need it everywhere. Some honest caveats. If your product is mostly reads of small documents by a single user, Firestore is probably still the right answer. If you're building social mechanics — feeds, follows, mentions, anything where one user's write affects another user's read — the moment you outgrow the toy phase, you'll want a database that understands joins. We don't regret starting on Firebase. We don't regret leaving it either.
FirebasePostgresMigrationRLS

Have something ambitious in mind?

We reply to every email within 48 hours. Call or async, whichever you prefer.