AbsoluteJS

sync vs Firebase

The most common "switched away" story across every sync-engine community thread starts with "I picked Firebase for MVP speed, regretted it by production." This page is the practical guide: what maps to what, what the migration script looks like, and what the realistic cost difference is on a worked example.

This page focuses on sync-vs-Firebase specifically. The substrate's operator-grade primitives (audit, OTel, dispatch, cluster bus, replay, migration) are consolidated on Substrate complete (G1–G7).

#Why teams leave Firebase

The dominant complaints are sourced and consistent across years of public discourse:

Surprise bills"$70k bill in one day" stories on HN. Spencer Pauly's viral migration post: "Firestore is optimizing to make their costs cheaper. Not yours."
No joins, no aggregationsOn Firestore Standard. Pipeline Operations API in Enterprise adds them back — at a price.
Vendor lock-inPauly again: "Firestore is the epitome of vendor lock-in." No portable migration; rewriting business logic to leave.
RTDB historical reliabilityA 13-hour outage on a customer's main product (HN #19047812). "Almost weekly, all clients sometimes wouldn't get notified of document changes."
1.2 MB document limitA hard wall for analytics/reporting screens.

sync's positioning is the literal anti-Firebase: self-hosted (no vendor), priced as your existing server's CPU (no surprise reads bill), uses your Postgres/MySQL/SQLite (no NoSQL tax, no document size cap, real joins), and the migration is just "swap your data layer" — nothing else has to move.

#TL;DR mapping

Firebase / Firestoresync (@absolutejs/sync)
Firestore document / collectionPostgres/MySQL/SQLite row / table
Hosted Google infrastructureYour existing Elysia server
Bills per read, write, GB, MAUFree (CC BY-NC 4.0 OSS); your CPU
No joins, no aggregationsOperator-graph joins + aggregates
Security rules DSL (Firebase-specific)TypeScript permissions on topics
Offline persistence (mobile)Local-first IndexedDB cache + queue
onSnapshot(query, cb)useSyncCollection({ collection })
doc.set(data)engine.runMutation('save', data)
Hosted-onlySelf-hosted, on the infra you have
Vendor lock-in (rewrite to leave)Drop sync, keep your DB

If the answer to "what changed?" is just "the data layer," you can do this migration without touching auth, the rest of your stack, or your hosting model.

#onSnapshot → sync collection subscribe

The client-side change is essentially mechanical: swap the import, swap the call. The semantics line up.

TS
// BEFORE — Firestore real-time listener.
import { onSnapshot, collection, query, where } from 'firebase/firestore';
import { db } from './firebase';

useEffect(() => {
  const q = query(
    collection(db, 'tasks'),
    where('ownerId', '==', userId),
  );
  const unsubscribe = onSnapshot(q, (snap) => {
    setTasks(snap.docs.map((d) => ({ id: d.id, ...d.data() })));
  });
  return unsubscribe;
}, [userId]);

becomes

TS
// AFTER — sync collection subscription.
import { useSyncCollection } from '@absolutejs/sync/react';

const { data: tasks } = useSyncCollection({
  url: 'ws://localhost:3000/sync/ws',
  collection: 'tasks_for_owner',
  params: { ownerId: userId },
});

// Reconnect catch-up is on by default — the WebSocket re-resolves
// after background-tab sleep, network flap, etc., with bounded ~4-6 ms
// catch-up regardless of missed-writes count. No silent drops the way
// onSnapshot can on long disconnects.

#Security rules → server-defined topics

Firestore security rules are a Firebase-specific DSL you ship next to the data. sync moves that into the same TypeScript file as everything else, with full context access (and tests that run with bun test).

TS
// Server side — define the 'tasks_for_owner' collection ONCE.
// This is the bit Firestore's security rules + onSnapshot try to do
// implicitly; in sync it's an explicit TypeScript function you can
// test, log, and version-control.

import { defineCollection } from '@absolutejs/sync/engine';

defineCollection({
  name: 'tasks_for_owner',
  key: (row) => row.id,
  hydrate: ({ db, params }) =>
    db.all('tasks WHERE owner_id = ?', [params.ownerId]),
  match: (row, params, ctx) =>
    row.ownerId === params.ownerId && row.tenant === ctx.tenant,
});

// 'match' is the per-row predicate the engine re-evaluates on every
// change. Conceptually equivalent to a Firestore security rule, but
// in TypeScript with full context access — no DSL to learn.

#doc.set() → mutations

Firestore writes are "set the doc; the server decides with security rules." When a write fails the security rule, the SDK silently rolls back the optimistic cache and the app has no app-visible signal. sync makes the server handler explicit, the optimistic draft a first-class API, and rejection a normal Promise reject.

TS
// BEFORE — Firestore write with offline persistence + optimistic UX.
import { doc, setDoc, serverTimestamp } from 'firebase/firestore';

const addTask = async (text) => {
  // Optimistic UI happens "for free" via the local cache, but if the
  // write violates security rules the SDK silently rolls back with no
  // app-visible signal. You have to wire your own error handler.
  await setDoc(doc(db, 'tasks', crypto.randomUUID()), {
    text,
    ownerId: currentUser.uid,
    createdAt: serverTimestamp(),
  });
};

becomes

TS
// AFTER — sync mutation with optimistic draft.
const { mutate } = useSyncCollection({ url, collection: 'tasks_for_owner' });

const addTask = (text) => mutate({
  name: 'addTask',
  args: { text },
  optimistic: (draft) => {
    draft.set({ id: crypto.randomUUID(), text, ownerId: userId, n: 0 });
  },
});

// Server side — the handler authoritatively decides the id, timestamp,
// and any computed fields. If it throws, the optimistic draft rolls
// back AND the caller's promise rejects with the real error.

import { defineMutation } from '@absolutejs/sync/engine';

defineMutation({
  name: 'addTask',
  handler: async (args, ctx, actions) => {
    if (typeof args.text !== 'string' || args.text.length === 0) {
      throw new Error('text required');
    }
    return actions.insert('tasks', {
      id: crypto.randomUUID(),
      text: args.text.trim(),
      ownerId: ctx.userId,
      createdAt: actions.now(),
    });
  },
});

#Cost worked example

The canonical Firestore cost surprise — and what the equivalent looks like on sync. Spencer Pauly's cost trap, re-costed: "20 posts shown on a feed cost 40 reads if not denormalized."

Worked exampleFirestore Blazesync
Cost of one feed load (20 posts + author names)40 reads (1 read for the post + 1 read for the author = 2 reads × 20 posts)One Postgres query per page-load (or one push frame per change when a subscriber is live)
Pricing modelReads: $0.06 per 100kA $5/month Hetzner box comfortably serves 1M page views
1M page views / month
With a join you'd need: still 20 + 20 reads × 1M = same $24.
40M reads = $24/month from this view alone$5/month — flat, regardless of how many users see the feed

On sync, the equivalent is a graph collection with a left join:

TS
import { defineGraphCollection } from '@absolutejs/sync/engine';

defineGraphCollection({
  name: 'feed_with_authors',
  graph: (g) =>
    g.from('posts')
     .leftJoin('users', { from: 'authorId', to: 'id' })
     .select({
       id: 'posts.id',
       title: 'posts.title',
       authorName: 'users.name',
     })
     .orderBy('posts.createdAt', 'desc')
     .limit(20),
});

The actual feature you wanted ("posts with author names") is one declarative collection, not a denormalisation strategy you have to rebuild every time the schema changes.

#Operator surface

Firebase's operator-grade story is managed: point-in-time recovery up to 7 days on Firestore (Enterprise extends), managed export/import to GCS buckets, hosted dashboards. The tradeoff is the vendor lock-in + cost surprises you came to leave. Sync ships composable primitives you wire into your host — fewer bells, more control.

Firebasesync
Point-in-time recovery (PITR) — managed, 7-day window, Firestore Standard / Enterpriseengine.replayTo({ at, tables? }) (1.22). Bounded change log; retention is yours to configure (changeLogRetainMs). The syncDevtools Replay panel (1.23) is the clickable demo.
Cloud Firestore Managed Export/Import (GCS buckets, operator console)engine.fence({ reason }) + exportSnapshot() + importSnapshot() (1.24). Three composable verbs the host choreographs — no "spin up the export job, wait, restore" loop.
Firestore Audit Logs (Cloud Logging, retention by org)@absolutejs/audit + withIntegrity (hash-chain tamper evidence; SHA-256 or HMAC-SHA256)
Cloud Trace / Cloud MonitoringOTel spans across every substrate package via @absolutejs/telemetry — wire ONE TracerProvider, every package emits.
Direction of tradeoff
Firebase ships "managed and done." Sync ships "primitives you own and operate." For teams who left Firebase because of bills + lock-in, owning the operator surface is the point — the substrate gives you the verbs so you don't rebuild them.

#Auth — keep Firebase Auth, or move

Auth is the one thing sync doesn't do — it's a data layer, not an identity provider. The migration pattern that fits 90% of teams:

1
Keep Firebase Auth as your identity provider
It's fine.
2
Validate the Firebase ID token
In your Elysia server's auth middleware (firebase-admin's verifyIdToken).
3
The verified token becomes the ctx object
sync sees it on every subscription and mutation.
TS
import { Elysia } from 'elysia';
import { syncSocket } from '@absolutejs/sync';
import { auth } from 'firebase-admin';

new Elysia()
  .derive(async ({ headers }) => {
    const token = headers['authorization']?.replace(/^Bearer /, '');
    if (!token) return { ctx: null };
    const decoded = await auth().verifyIdToken(token);
    return { ctx: { userId: decoded.uid, tenant: decoded.firebase?.tenant } };
  })
  .use(syncSocket({ engine }))
  .listen(3000);
If you'd rather move auth too
@absolutejs/auth ships OAuth (Google, GitHub, Apple, etc.), credentials, MFA, sessions, and the Firebase-style "just call onAuthStateChanged" client primitive. Migration is independent of the sync migration; do them in either order.

#One-shot migration script

The actual data move. Run it while Firebase stays live; tail a delta at cutover.

Use the firebase CLI to dump each collection to JSON (one doc per line), then stream into sync's destination DB through Drizzle/Prisma/raw SQL. The example moves the tasks collection to a tasks Postgres table.

TS
// One-shot migration: 'tasks' Firestore collection → 'tasks' Postgres table.

import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { readFileSync } from 'node:fs';
import { tasks } from './schema';

const sql = postgres(process.env.DATABASE_URL);
const db = drizzle(sql);

// firebase firestore:export gs://my-bucket/dump
// gcloud storage cp -r gs://my-bucket/dump/all_namespaces/...tasks ./tasks
// jq -c '.documents[] | { id: .name, ...(.fields | with_entries(...)) }' \
//   ./tasks/output-0 > tasks.jsonl

for (const line of readFileSync('./tasks.jsonl', 'utf8').trim().split('\n')) {
  const doc = JSON.parse(line);
  await db.insert(tasks).values({
    id: doc.id,
    text: doc.text,
    ownerId: doc.ownerId,
    createdAt: new Date(doc.createdAt),
  }).onConflictDoNothing();
}
Two pragmatic notes
  1. Run the migration with the Firebase site still live; you're copying, not migrating away yet. Re-run a tail-only delta when you cut over.
  2. Date/timestamp normalization is the most common gotcha. Firestore timestamps come back as { _seconds, _nanoseconds }; convert to JS Date or ISO string before the insert.