Firebase vs Convex: Where Authorization Lives
Rules in a DSL or logic in functions — judged on a shipped schema, including the check-then-act race that proved declarative rules can't hold an invariant.
Firebase and Convex both hand you a database, auth and serverless functions, so the feature grids look interchangeable. They are not, and the axis that separates them is where authorization lives: Firebase puts it in a declarative rules file next to the data, Convex puts it inside the functions that touch the data. Everything else follows from that choice.
The short answer
Choose Firebase if you need breadth — mobile SDKs with offline-first sync, push messaging, crash reporting, hosting and auth from one vendor — and you can live with a schemaless store and a rules DSL. Choose Convex if you are building a TypeScript web app and want end-to-end types, a declared schema, and reactive queries that update themselves, and you do not need the mobile ecosystem.
If you want the comparison against a relational backend instead, we have Supabase vs Convex and Firebase vs Postgres.
The comparison
| Firebase | Convex | |
|---|---|---|
| Data model | Firestore documents, schemaless | Documents with a declared TypeScript schema |
| Where authorization lives | A declarative rules file (firestore.rules) | Inside query/mutation functions |
| Types across the wire | Generated client types, schema not enforced at write | Inferred end to end from the schema |
| Realtime | Snapshot listeners you subscribe and unsubscribe | Reactive queries that re-run automatically |
| Transactions | Explicit transactions and batched writes, with limits | Mutations are transactional by default |
| Server logic | Cloud Functions, separate deploy unit | Functions are the API surface |
| Self-host | No (client SDKs are open source) | Yes — backend open-sourced under a fair-source licence |
| Mobile | The strongest story here: native SDKs, offline persistence | Web-first; mobile via the JS client |
| Maturity | Years of production use, huge answer corpus | Younger: fewer tutorials, fewer third-party integrations |
| Vendor exit | Firestore export, then a rewrite of rules and queries | Self-host the backend, or rewrite the functions |
Both vendors change pricing often enough that any number here would be stale; read their own pages, and model the dimension that actually scales for you — document reads for Firestore, function calls and bandwidth for Convex.
Why we can speak to the axis that matters
This storefront runs neither. It runs Postgres on Supabase, which is a relational third option — so take nothing here as a verdict on either product's dashboard.
What it does give us is the one thing a feature grid cannot: we have shipped both authorization styles in production, on the same schema, and we know which jobs each one is good for. Reads here are protected declaratively, by row-level security policies. Writes are protected by functions. That split was not a preference; it was forced by a bug. Firebase-versus-Convex is, underneath, a question about that split, and it has a concrete answer.
Declarative rules: excellent, and strictly limited
Here is the declarative half of this project's authorization — four of its six policies, from supabase/migrations/0001_commerce_init.sql (0008 added two more in the same shape):
alter table public.profiles enable row level security;
alter table public.orders enable row level security;
alter table public.entitlement_slots enable row level security;
alter table public.download_events enable row level security;
create policy "own profile" on public.profiles for select using ((select auth.uid()) = id);
create policy "own orders" on public.orders for select using ((select auth.uid()) = user_id);
create policy "own slots" on public.entitlement_slots for select using ((select auth.uid()) = user_id);
create policy "own downloads" on public.download_events for select using ((select auth.uid()) = user_id);
This is the same shape as a Firestore rule that compares request.auth.uid to a field on the document, and it has the same virtues. It is short enough to review in one sitting. It cannot be bypassed by forgetting a where clause in application code. It applies to every client, including one someone writes against your API next year.
Note what the four lines do not grant: they are for select only. No client role can insert, update or delete any of these tables. Everything that changes state goes through a function — which is the Convex posture, arrived at from the other direction.
One implementation detail worth stealing if you write Postgres policies: (select auth.uid()) is wrapped in a scalar subquery rather than called bare. That lets the planner evaluate it once per statement instead of once per row, which is the difference between a policy you can afford on a large table and one you cannot.
The bug that decides the argument
Declarative rules express who may touch this row. They cannot express how many times per hour, because that is a claim about rows that do not exist yet.
This codebase learned it concretely. Downloads were rate-limited by reading a count and then inserting an audit row — two statements. From the migration that fixed it, supabase/migrations/0007_atomic_download_rate_limit.sql:
Root cause (CWE-367 TOCTOU / OWASP A04): the download endpoint enforced the hourly limit with a non-atomic check-then-act — read
downloads_in_last_hour(), then a SEPARATE insert of the audit row. Under concurrency, N requests could all read the same count, all pass the< limitcheck, and all insert, so a burst of parallel requests bypassed the cap.
No rules file fixes that. The check and the write have to become one operation:
-- Serialize concurrent downloads for THIS user so count+insert is atomic.
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text, 0));
select count(*)::int into recent
from public.download_events
where user_id = p_user_id
and created_at > now() - interval '1 hour';
if recent >= p_limit then
return -1; -- over limit — record nothing
end if;
insert into public.download_events
(user_id, product_slug, framework, version, ip, user_agent)
values
(p_user_id, p_product_slug, p_framework, p_version, p_ip, p_user_agent);
return recent + 1;
Three things in that excerpt generalise to both products:
- The lock is taken before the read. Lock-then-read is the whole fix; read-then-lock preserves the race.
- It is keyed per user, so two different buyers never contend.
- It depends on the isolation level. The migration documents it: correctness relies on
READ COMMITTED, because theselectthen takes a fresh snapshot after the previous holder committed. UnderREPEATABLE READthe snapshot could freeze earlier and the second caller could read a stale count.
That third point is the one to carry into either vendor. Convex's model matches this shape directly — a mutation is a transactional function, so "count then conditionally insert" is one unit by construction, and you do not reach for an advisory lock to get there. Firebase can also do it, with a transaction or a counter document, but the enforcement has moved out of firestore.rules and into a Cloud Function, and now you must also stop clients writing that collection directly — or the rule and the function disagree and the function loses.
The honest summary of the axis: declarative rules are the better tool for ownership, and they are not a tool for invariants. Any product that offers only rules will push your invariants into functions eventually; Convex starts there, and Firebase gets there after an incident.
Schema: a file you write versus a shape you hope for
The other real divergence is whether the database knows what your data looks like.
Firestore does not, by design. Nothing rejects a document whose total_cents arrives as the string "4900". In exchange you get the fastest possible early iteration and no migration step. Convex takes the opposite trade: you declare the schema in TypeScript, the client types are inferred from it, and a write that does not match is rejected.
This project's equivalent is eight numbered SQL files, and their names are the argument for a declared schema:
0001_commerce_init.sql 0005_retain_anonymized_download_audit.sql
0002_display_name_allowlist.sql 0006_pricing_v2_slot_kinds.sql
0003_commerce_rpcs.sql 0007_atomic_download_rate_limit.sql
0004_lock_down_rate_limit_fn.sql 0008_refund_requests.sql
Four of those eight are security or correctness repairs to earlier ones, each a reviewable diff with a dated rationale. That is the real benefit of a declared schema and it is not type safety — it is that the history of what you believed about your data is written down. A schemaless store gives you no place to record that 0004 locked down a function 0001 left open. You find out from an audit instead.
Where this bites hardest is a field whose meaning changes. 0006_pricing_v2_slot_kinds.sql reshaped entitlement slot kinds after the pricing model changed. In Firestore that is a backfill script plus a period where both shapes are live and every reader must handle both; in Convex or Postgres it is a migration with a known before and after.
What neither of them sells you, and you may not need
Convex's headline is reactive queries; Firestore's is snapshot listeners. Both are genuinely good, and this codebase uses zero realtime — no subscription, no socket, which is also why its Content-Security-Policy has no wss: entry at all.
That is worth saying plainly because realtime is the feature most likely to decide a comparison for reasons that will not survive contact with the product. A storefront's data changes when a release ships, not when a visitor is looking at it. If your app is a catalogue, a marketing site, a dashboard over slow-moving data, or anything where a refresh is an acceptable answer, reactive queries are a feature you pay for in architecture and never cash in.
Where it genuinely decides things: collaborative editing, presence, live chat, a feed that must update without interaction, or a mobile client that has to work offline and reconcile later. That last one is Firebase's strongest remaining claim and Convex does not match it today.
Keep the rules out of the vendor
One pattern survives either choice, and it is the cheapest insurance in this whole comparison: keep your business rules in plain functions with no database access.
src/lib/entitlements.ts here has zero imports. It takes a list of slots and a download target and answers whether the download is allowed:
export function slotCovers(slot: Slot, target: DownloadTarget): boolean {
if (slot.status !== "active") return false;
switch (slot.kind) {
case "all_access":
return true;
case "all_templates":
return target.framework !== "backend";
case "template_single":
return (
slot.productSlug === target.productSlug &&
target.framework !== "backend"
);
}
}
export function authorizeDownload(slots: Slot[], target: DownloadTarget): boolean {
return slots.some((s) => slotCovers(s, target));
}
Because it touches nothing, it is unit-testable without a database, a server or a mock — and the licence rules that decide who may download what are tested as arithmetic. The vendor-specific code is reduced to fetching rows and calling this.
The reason that matters to a Firebase-versus-Convex decision: whichever you pick, this file does not change. Rules written in firestore.rules are not portable or testable the same way; logic buried in a Convex mutation is testable but tied to its runtime. Pull the decisions out, leave the plumbing in, and the vendor becomes a replaceable layer rather than the shape of your application.
Troubleshooting and migration notes
| Situation | What goes wrong | What to do |
|---|---|---|
| Porting Firestore rules to Convex | There is no rules file; the mapping is one guard per function | Write an assertOwner(ctx, doc) helper and call it first in every function |
| Porting Convex functions to Firebase | Mutations were transactional by default; Firestore writes are not | Wrap multi-document writes in a transaction or batch explicitly |
| A rate limit that passes tests and fails in production | Check-then-act under concurrency | Make count and write one operation; lock before you read |
| Rules allow a write a function also validates | Clients can bypass the function | Deny direct client writes to that collection; the function is the only path |
| Counts drift after a burst | Non-atomic increments | Use a transaction, or a server-side function keyed per user |
| Types pass, data is wrong shape in the store | Firestore does not enforce the schema at write | Validate at the boundary; types are not a runtime check |
| Query is fast in dev, slow with real data | Missing index — both products need them | Add the index; our download audit carries (user_id, created_at) for exactly this |
| An old field means something new | No recorded migration | Write the migration as a file, even if the store does not require one |
Frequently asked questions
Is Convex production-ready? It is being used in production, and it is younger than Firebase — the practical cost is a thinner corpus of answers when something breaks at 2am, and fewer third-party integrations. Weigh that against end-to-end types, which remove a class of bug entirely. If your team is small and TypeScript-fluent, the trade usually favours Convex; if you need an answer to every question to already exist, it favours Firebase.
Can I self-host either one? Convex open-sourced its backend, so yes, with real operational work. Firebase is not self-hostable — the client SDKs are open source, the service is not. If "we must be able to run this ourselves" is a hard requirement, the comparison is already decided.
Which is safer by default? Neither; they fail differently. Firebase's rules are easy to write and famously easy to leave permissive — a misconfigured ruleset is one of the most common data exposures in the category. Convex's functions cannot be left open in the same way, because there is no public table surface, but every function must remember its own authorization check, and a forgotten check is invisible until someone looks. Declarative rules fail open and visibly; function guards fail open and quietly.
Do I need realtime? Probably less than the marketing suggests. Ask whether a user would notice a stale screen before their next interaction. If not, you are paying architecture for a feature you will not use — this codebase has none and has never wanted it.
Templates in this post
ASoc Pip (a forex-trading site with a live-rates dashboard preview), ASoc Press (a blog, news and magazine template) and ASoc Quest (a dark-theme games storefront) are front ends with no backend assumptions baked in, so either stack above drops in behind them.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the relational side of the same decision, see Convex vs Prisma.
