Migration · 2026-04-28 · By RedDB team · 8 min read

Out of Firebase, into a real backend

A migration playbook for teams that outgrew Firebase — auth bridge, Firestore doc model, security rules, real-time — and an honest look at what you lose when the magic console goes away.

Firebase is a fantastic product for the first six months of a startup and an expensive constraint somewhere around month eighteen. This post is for the teams who are past month eighteen.

We have done this migration four times for customers in the last year. Same shape every time: founders love Firebase, the senior engineer who joined to “harden the backend” hates it, and somewhere in between is a real product with paying users that cannot afford a multi-week outage. The walkthrough below is the playbook we hand to the senior engineer on day one. It is not “Firebase bad, please switch” — it is “here is the order of operations, here is what is genuinely worse on the other side, and here is how you keep the lights on while you cut over.”

Why teams leave (be honest about this first)

Before any migration, name the pain. We see four recurring drivers, roughly in order of severity:

  1. Cost cliff at scale. Firestore reads at $0.06 per 100k start to dominate the bill once you have a real timeline or feed product. The pricing model penalises the exact access pattern (fan-out reads) that makes Firebase pleasant to build on.
  2. Query expressiveness ceiling. Firestore has no joins, no aggregations beyond count, and composite indexes you have to predeclare. The third time someone says “we’ll just denormalise it” is the moment you have built a relational schema in a non-relational database without any of the relational tools.
  3. Vendor lock-in starts to hurt. Security rules, Functions, and the Firebase SDK leak deeply into the app. Migrating later is a bigger project than migrating now. Senior engineers who have seen this movie before push for the cut earlier rather than later.
  4. Observability gap. Firebase Console is wonderful for the first 100 documents and useless at 100 million. You cannot EXPLAIN ANALYZE a Firestore query. You cannot trace why a single document read is slow.

If your pain is something else — say, you simply do not like Google as a vendor — be careful. Migrations are expensive. Spend the migration budget where the pain actually is.

The four bridges you are about to build

A Firebase-to-RedDB migration is four parallel bridges that you cut over independently:

BridgeFirebase sideRedDB side
AuthFirebase Auth tokensRedDB sessions (or your own JWT)
DocumentsFirestore collectionsRedDB tables with JSON columns
AuthorizationSecurity rulesRow-level policies
Real-timeonSnapshot listenersChange streams over websocket

Build all four. Cut over auth first (it is the foundation), then run the document layer in dual-write, then move security to the server, then finally swap real-time. The order matters because each bridge depends on the previous one being stable.

Bridge 1: auth

Firebase Auth gives you a signed JWT per user with claims like sub (Firebase UID) and email. RedDB does not care where the JWT came from as long as you can verify it. The migration is therefore a verification gateway: trust Firebase tokens during the cutover, and issue your own RedDB session in exchange.

// During cutover, your /auth/bridge endpoint accepts a Firebase ID token,
// verifies it against Google's public keys, and returns a RedDB session.

import { verifyIdToken } from 'firebase-admin/auth'

export async function bridgeFirebaseToken(idToken: string) {
  const decoded = await verifyIdToken(idToken)
  // decoded.uid is the Firebase UID — keep it on the user row for lookup.

  let user = await reddb.query(
    'select id from users where firebase_uid = $1',
    [decoded.uid],
  ).first()

  if (!user) {
    user = await reddb.query(
      `insert into users (firebase_uid, email, created_at)
       values ($1, $2, now())
       returning id`,
      [decoded.uid, decoded.email],
    ).first()
  }

  return issueSession(user.id) // your own opaque session token
}

Two things to notice. First, firebase_uid becomes a permanent column on users — keep it forever; it is your join key for any future Firebase data you discover. Second, the endpoint creates the RedDB user row lazily on first sign-in. This means the cutover does not require a “migrate every user up front” batch job — users arrive as they sign in.

After three to four weeks, every active user has a RedDB session and you can issue your own tokens directly. Firebase Auth becomes a fallback for the long tail.

Bridge 2: documents

This is the largest bridge. Firestore’s collection-document model maps cleanly to a table with one JSON column, but the lazy translation is a trap. Naive port:

create table firestore_documents (
  collection text not null,
  doc_id     text not null,
  data       jsonb not null,
  updated_at timestamptz not null default now(),
  primary key (collection, doc_id)
);

This works for week one and then you realise every query is a full table scan or a hand-built JSONB GIN index per access pattern. Do not do this.

Instead, model each Firestore collection as a real RedDB table. Pull the fields you actually query into typed columns and keep an extra JSONB column for the long tail. Example: a Firestore users/{uid} document with { email, displayName, plan, preferences: {...} } becomes:

create table users (
  id           uuid primary key default gen_random_uuid(),
  firebase_uid text unique,
  email        text not null,
  display_name text,
  plan         text not null default 'free',
  preferences  jsonb not null default '{}'::jsonb,
  created_at   timestamptz not null default now(),
  updated_at   timestamptz not null default now()
);

create index users_plan_idx on users (plan);

The plan column gets the index because you filter on it. preferences stays in JSONB because no one queries inside it — it is read whole and written whole.

Dual-write during cutover

You cannot atomically migrate a live document store. Instead, run a window of dual-write: every write goes to both Firestore and RedDB, every read is configurable per surface. The pattern that survives production looks like this:

async function updateUserPlan(uid: string, plan: string) {
  // 1. Write to RedDB first (it is the new source of truth).
  await reddb.query(
    `update users set plan = $1, updated_at = now()
     where firebase_uid = $2`,
    [plan, uid],
  )

  // 2. Best-effort mirror to Firestore. If it fails, log — do not roll back.
  try {
    await firestore.collection('users').doc(uid).update({ plan })
  } catch (err) {
    logger.warn({ err, uid }, 'firestore mirror failed — already on reddb')
  }
}

RedDB is the new source of truth from day one of cutover. Firestore is a write-behind cache for any old client code you have not yet migrated. The asymmetry is deliberate: you can read stale data from Firestore for a week, but you cannot lose data that is now only on RedDB.

The backfill job

For documents that existed before the cutover, a one-shot backfill copies them across. Use SKIP LOCKED so multiple workers can run in parallel without stepping on each other:

with batch as (
  select id from firebase_users_to_migrate
  where migrated_at is null
  order by id
  limit 500
  for update skip locked
)
update firebase_users_to_migrate m
set migrated_at = now()
from batch
where m.id = batch.id
returning m.id, m.firestore_doc;

The worker reads firestore_doc (a JSON snapshot loaded from Firestore), translates it to typed columns, inserts into users, and marks the row migrated. Idempotent on firebase_uid uniqueness — safe to re-run.

Bridge 3: authorization (security rules → row-level policies)

Firestore security rules are a thicket of match /users/{uid} { allow read: if request.auth.uid == uid } declarations that mix authentication, authorization, and validation into a single mini-DSL. The good news: every rule you actually use translates to one of three RedDB patterns.

Pattern A: owner-only access.

create policy users_self on users
  using (id = current_setting('app.user_id')::uuid);

The application sets app.user_id at the start of each request based on the verified session. Every query against users is implicitly filtered.

Pattern B: membership-based access.

create policy team_member on documents
  using (
    exists (
      select 1 from team_members
      where team_members.team_id = documents.team_id
        and team_members.user_id = current_setting('app.user_id')::uuid
    )
  );

Pattern C: role-based access.

create policy admin_all on audit_log
  for select
  using (current_setting('app.user_role') = 'admin');

Most Firestore rules are some combination of these three. The translation is mechanical; the win is that policies live in the database and apply to every query path — including the ad-hoc psql session you open at 2am, which Firestore could never protect.

Bridge 4: real-time

This is the bridge where you lose the most magic, and we will not pretend otherwise.

Firebase onSnapshot is genuinely brilliant. You attach a listener to a query and your UI updates whenever any document in the result set changes — with no server you have to write, no websocket you have to manage, no reconnection logic. It is the killer feature.

The RedDB equivalent is a change-stream subscription over websocket. You write the websocket server (or use ours) and clients subscribe to a query. The UX is the same; the implementation is now your problem.

// Server: stream changes for a user's documents.
reddb.subscribe('user_documents', {
  where: 'user_id = $1',
  params: [userId],
}).on('change', (event) => {
  ws.send(JSON.stringify(event))
})

What gets worse: you now own connection management, reconnection, backpressure, and the “what happens when the client missed 30 seconds of updates” question. Firebase handled all of this transparently. Plan for two engineer-weeks on this bridge alone if you do not already have a websocket layer.

What gets better: the change stream is a real database stream. You can filter, transform, batch, and join it with other tables. You can replay from any LSN. You can hook into it for analytics, audit, or webhooks without paying Firestore for a second read of every document.

The things that are genuinely worse

Honest reckoning. Here is what you give up:

  1. Firebase Console. There is no equivalent admin UI on day one. You will write queries in psql or build a small internal admin tool. Plan a sprint for this; do not pretend the existing Postgres GUIs are a substitute for non-technical staff.
  2. Offline-first mobile SDK. Firestore caches and syncs offline. RedDB does not. If you have an offline-heavy mobile app, you are building a sync layer yourself — this might be a reason to delay the migration until you have the staffing for it.
  3. Magic scaling for write-heavy fan-out. Firestore happily eats a million-write fan-out and bills you for it. RedDB will too, but you will see the load on your own servers and have to provision for it. The bill is more visible (which is the point) but so is the operational responsibility.
  4. Drop-in mobile auth. Firebase Auth’s SDK handles password reset, social login, MFA, and email verification out of the box. You can replicate all of this on RedDB — but it is now code you maintain, not a checkbox in the console.

If you are honest about the four above and your team still wants to migrate, you are ready.

A four-week cutover plan

Week 1: Stand up RedDB, build the auth bridge, run dual auth (Firebase Auth verifies, RedDB session issued). Zero user-visible changes.

Week 2: Build the document tables for your top-three highest-traffic collections. Start dual-write. Backfill historical data. Reads still come from Firestore.

Week 3: Flip reads to RedDB for those three collections. Monitor for one week. Migrate the remaining collections in priority order.

Week 4: Translate security rules to row-level policies. Cut over real-time listeners. Begin decommissioning Firestore reads. Keep Firestore as a write-behind mirror for two more weeks as insurance.

Past four weeks: turn off the Firestore mirror, cancel the contract, write the postmortem.

TL;DR

Migration is four bridges (auth, documents, authorization, real-time) cut over in order. Dual-write keeps you safe through the cutover; RedDB is the source of truth from day one. You lose Firebase Console, offline-first sync, and SDK magic — be honest about which of those actually matter for your product before you start. Plan four weeks, expect six, and budget one full engineer for the websocket layer if real-time matters to you.

The companion piece in this series (When NOT to migrate: a checklist) is just as important. If your pain is not on the list at the top of this post, the right answer might be to stay on Firebase for another year. Migrations are expensive — spend the budget where the pain actually is.

© 2026 RedDB.io. AGPL-3.0 self-host · Managed Cloud.