Supabase vs. Convex: A Lock You Write vs. a Transaction You Get
Convex's transaction model would have prevented a real TOCTOU race this storefront hit in its Postgres download limiter. Why that's true, and why the fix stayed on Supabase anyway.
Supabase wraps Postgres — you write SQL, enforce invariants with functions and row-level security, and the database is the source of truth. Convex replaces the database with TypeScript: your mutations are the backend, executed transactionally with no SQL and no ORM. This storefront runs on Supabase, and its one real concurrency bug — a check-then-act race in the download rate limiter — is exactly the class of defect Convex's transaction model rules out by construction. That is a genuine point in Convex's favor, and also not the whole story.
The decision in one table
| Supabase (what this storefront runs) | Convex | |
|---|---|---|
| Data model | Postgres tables, SQL, foreign keys | Documents, defined in TypeScript, no SQL |
| Where business logic lives | SQL functions (SECURITY DEFINER RPCs) or app code | TypeScript mutations, run inside Convex's own runtime |
| Transaction guarantee | You ask for it — locks, SECURITY DEFINER, transaction scope, all explicit | Every mutation is transactional and serialized by default |
| Reading changed data | Poll, or subscribe to Postgres's replication stream via Realtime | Queries are reactive by default — no separate subscribe step |
| Query language | SQL, and everything SQL can express (using clauses, joins, aggregates) | TypeScript functions over a document store; no ad hoc SQL |
| Self-hosting | Real, but secondary to the managed cloud most projects run on | Open source since Feb 2025; the primary distribution is still Convex's own cloud |
| Auth | Postgres RLS reads the JWT's claims directly in a using clause | Convex auth integrates via a third-party provider (Clerk, Auth0, etc.) or custom JWT |
The bug Convex's model would have prevented
This codebase shipped a real TOCTOU race (CWE-367): the download endpoint checked the caller's hourly download count, then inserted an audit row, as two separate statements. Under concurrent requests, several could all read the same count, all pass the < limit check, and all insert — the cap was enforceable in isolation but not under load. The fix is record_download_within_limit(), a SECURITY DEFINER Postgres function that does the count-and-insert in one transaction-scoped advisory lock keyed on the caller's user id, so concurrent requests from the same user queue instead of racing. The gated-downloads post has the full function and the isolation-level caveat that makes the lock correct — this post cites it rather than re-deriving it.
That fix is nine lines of plpgsql this team had to write, test, and reason about under a specific isolation level (READ COMMITTED). Convex's mutation model doesn't have this bug class available to write: every mutation runs against a consistent snapshot and is serialized with the others touching the same documents, so a "read count, then insert" mutation is atomic by default, not by a lock someone remembered to add. If this storefront's commerce layer had been built on Convex from the start, this specific defect would not have shipped, because the ordinary way to write the mutation is already the correct way.
That is the honest case for Convex on this axis. It doesn't generalize to "Convex is safer" — it generalizes to "Convex's default is a transaction, Postgres's default is a statement," and a team using Postgres has to know when it needs the stronger guarantee and ask for it explicitly, the way this fix did after the race was already found in review rather than before it shipped.
The reactivity nobody asked for here
Convex's other pitch is that a query result updates itself the moment the underlying data changes, no subscribe call written by hand. This codebase has exactly zero uses of Supabase's Realtime feature — grep src/ for realtime, subscribe, or a channel listener and the only hits are useWishlist.ts's local pub/sub (a plain subscribeWishlist() function, localStorage-backed, no server involved at all) and three other Supabase-comparison posts mentioning the word in passing. Every server-backed read in this app — entitlements, order status, product data — is fetched fresh on the request that needs it, not watched.
That isn't an oversight; it's a fit between the data and the read pattern. Entitlement state changes exactly twice in its lifecycle: once when a webhook grants it, once (rarely) when a refund revokes it. A dashboard page checking entitlements on load is checking a value that is, in practice, static between visits — there is nothing here that benefits from a push. Convex's reactive-by-default queries are the right primitive for a collaborative document, a live dashboard, a chat feed — data that changes while someone is looking at it. A catalog of ~111 products backed by an 8,100-line typed array (counted here) and a commerce layer that mutates on a webhook, not on a keystroke, never needed the push channel Convex would have given it for free. Paying for reactive queries you don't use isn't a Convex cost specifically — it's the cost of any real-time layer applied to data that doesn't change in real time.
What SQL still buys, that this comparison shouldn't skip
The RLS policy set behind this storefront is six policies, all select, zero writes — every mutation goes through a SECURITY DEFINER RPC instead of a direct table write, so the database enforces "you can only read your own entitlement rows" with a using (user_id = auth.uid()) clause the query planner evaluates, not application code that has to remember to filter. Convex's document model can express ownership checks in a mutation or a custom auth rule, but it's TypeScript you write per function, not a declarative predicate the storage layer enforces for every query against a table regardless of which code path reaches it. The Appwrite comparison makes the same point against a role-grant permission model; against Convex the comparison is SQL-declarative-and-storage-enforced versus TypeScript-and-function-enforced, a different axis than role-based-versus-predicate-based, but the same underlying question: does the guarantee live in the schema, or in code someone has to write correctly every time.
The self-hosting question is a one-line aside here, not the lead — the Appwrite post already covers why this storefront runs Supabase's managed cloud rather than exercising Postgres's self-host option. Convex went open source in 2025, but as with Supabase, its primary distribution is the managed cloud; neither platform's self-hosting story changes anything else in this comparison.
Where Convex is straightforwardly the better answer
- A collaborative or live-updating app — a shared whiteboard, a chat feed, a dashboard multiple people watch simultaneously — where "the query result changed under you" is the feature, not a race to guard against.
- A team that wants transactional-by-default mutations without writing SQL, and is fine trading Postgres's query power and ecosystem for TypeScript functions as the entire backend.
- Greenfield apps with no existing relational data. Migrating a schema with real foreign keys and joins into Convex's document model is a redesign, not a port; a new app has no such migration to do.
None of those describe a 111-product catalog that is mostly static content plus a commerce layer that mutates on a payment webhook. That's the shape this comparison actually turns on — how often the data changes while someone is looking at it, not which platform is newer.
Mistakes and how they show up
| Mistake | How it shows up | Fix |
|---|---|---|
| Assuming Postgres gives you Convex's transaction guarantee for free | A check-then-act sequence races under concurrency, exactly like the download-limit bug here | Wrap it in a SECURITY DEFINER function with an explicit lock, or move it into one statement |
| Adding Realtime subscriptions because the platform offers them | Extra client JS and open connections for data that only changes on a webhook | Fetch fresh on read; subscribe only where the read pattern is genuinely live |
| Treating Convex's reactivity as free performance | Every reactive query has to be re-evaluated when its inputs change, which costs compute even when nobody's watching | Reserve it for views someone is actually looking at, not background data |
| Porting a relational schema into Convex's document model unchanged | Joins that were one SQL statement become several round-trip queries in application code | Denormalize deliberately for the access patterns your mutations actually need |
| Comparing the two on "which is more modern" | Skips the actual question — whether your data changes on user interaction or on infrequent server events | Look at how often each table in your schema actually mutates and who is watching when it does |
Frequently asked questions
Would Convex have prevented every bug in this storefront's commerce layer? No — it would have prevented this specific class (concurrent check-then-act races on a mutation), because Convex mutations are serialized by default. It wouldn't touch bugs in webhook signature verification, entitlement logic, or anything that isn't a race condition; those are correctness bugs a transaction guarantee doesn't fix.
Is Convex's document model just Firebase again? No. The Firebase comparison covers Firestore, a NoSQL document database with security rules as a separate declarative layer. Convex's documents are typed in TypeScript and read/written through functions you write yourself — closer to "your backend is a set of RPCs" than to a rules-gated document store.
Does this storefront need Convex's reactivity if it ever adds a live feature? If a genuinely collaborative or live-updating view showed up — multiple admins editing the same order, say — Supabase's Realtime (built on Postgres's replication stream) covers that without a platform migration. The zero-usage fact above is about what this app needs today, not a ceiling on what Postgres can do.
Can Supabase express the same transactional guarantee Convex gives by default?
Yes, explicitly: wrap the operation in a SECURITY DEFINER function and take a lock scoped to what needs serializing, exactly like record_download_within_limit() does. The difference is that Postgres makes you ask for it per operation; Convex's mutation model makes it the default you'd have to opt out of.
Templates in this post
ASoc Zenith is a growth-marketing-studio site built around a metrics hero, a six-service grid, and four detailed case studies — a marketing surface with no backend of its own, the kind of static-content page that never needed either platform's mutation model. ASoc Aegis markets risk-management software with a Risk Center dashboard preview and a three-tier pricing table. ASoc Ally is an AI support-chatbot site with a live chat-widget preview and an eight-feature grid — the closest of the three to a genuinely live product, and a reasonable candidate for exactly the reactive-query use case this post argues Convex is built for.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the permission-model half of the backend question, Supabase vs. Appwrite; for the download-limiter race this post cites, the gated-downloads walkthrough.
