Skip to main content
ASoc
Comparison

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.

The ASoc Team9 min read

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 modelPostgres tables, SQL, foreign keysDocuments, defined in TypeScript, no SQL
Where business logic livesSQL functions (SECURITY DEFINER RPCs) or app codeTypeScript mutations, run inside Convex's own runtime
Transaction guaranteeYou ask for it — locks, SECURITY DEFINER, transaction scope, all explicitEvery mutation is transactional and serialized by default
Reading changed dataPoll, or subscribe to Postgres's replication stream via RealtimeQueries are reactive by default — no separate subscribe step
Query languageSQL, and everything SQL can express (using clauses, joins, aggregates)TypeScript functions over a document store; no ad hoc SQL
Self-hostingReal, but secondary to the managed cloud most projects run onOpen source since Feb 2025; the primary distribution is still Convex's own cloud
AuthPostgres RLS reads the JWT's claims directly in a using clauseConvex 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

MistakeHow it shows upFix
Assuming Postgres gives you Convex's transaction guarantee for freeA check-then-act sequence races under concurrency, exactly like the download-limit bug hereWrap it in a SECURITY DEFINER function with an explicit lock, or move it into one statement
Adding Realtime subscriptions because the platform offers themExtra client JS and open connections for data that only changes on a webhookFetch fresh on read; subscribe only where the read pattern is genuinely live
Treating Convex's reactivity as free performanceEvery reactive query has to be re-evaluated when its inputs change, which costs compute even when nobody's watchingReserve it for views someone is actually looking at, not background data
Porting a relational schema into Convex's document model unchangedJoins that were one SQL statement become several round-trip queries in application codeDenormalize 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 eventsLook 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.

Keep reading

Comparison12 min read

Supabase vs Firebase for a Template-Based SaaS

Authorization is where they differ most and what you cannot change later. Row-Level Security vs Security Rules, the Server Component fit, and the 68 KiB our own auth SDK cost.

Read more
Comparison8 min read

Supabase vs MongoDB: The Question Isn't the App, It's the Dataset

Our 8,114-line, 111-product catalog is document-shaped — and lives in a typed array, not a database. The commerce layer is relational, on Postgres. Why the split decides, not the app.

Read more
Comparison10 min read

Supabase vs Neon: Both Are Postgres, So the Database Isn't the Call

Six RLS policies, all built on auth.uid() — a function Postgres does not have. An audit of exactly which of our own code depends on the platform rather than the database.

Read more