The first time I opened the Convex dashboard and watched a database change update a React component across two browser windows in under 100 ms — without me writing a subscription, a websocket handler, or a single cache invalidation line — I sat back in my chair and thought: oh, this is the thing people have been trying to build for ten years.
That isn't hype. Convex is the first backend-as-a-service I've used where the abstraction matches how I'd actually want to write a real-time app, and where the trade-offs (there are some, I'll cover them) feel honest instead of marketing. This post is what I wish someone had shown me on day one: what Convex actually is, how its three function primitives work, why the reactive model is different from Firebase or Supabase, and where it shines and where it doesn't.
What Convex is, plainly
Convex is a reactive backend-as-a-service built for TypeScript. It bundles six things that are normally six different services:
- A document database with ACID transactions and indexed queries
- Server functions — queries, mutations, and actions — written in TypeScript
- A reactive sync engine that pushes query results to every subscribed client the instant the underlying data changes
- A workflow / scheduler for cron jobs and durable background work
- Vector and full-text search
- File storage with built-in upload helpers
You write your schema and your functions. Convex deploys them, runs them transactionally, watches the query dependencies, and pushes invalidated results to clients. There's no Redis to wire, no websocket gateway to scale, no separate job runner, no cache layer to invalidate. The platform does all of it.
The company was founded by ex-Dropbox engineers — Jamie Turner (CEO, led Dropbox Storage, Databases, Business Platform) and James Cowling (CTO, MIT PhD under Barbara Liskov, built Dropbox's multi-exabyte storage). That pedigree shows up in the transaction model: it's not "eventually consistent JSON store" — it's ACID over a real document store with serializable isolation.
The three function primitives
Everything you write on the server is one of three things: a query, a mutation, or an action. They look almost identical syntactically, but they have very different guarantees.
// convex/tasks.ts
import { query, mutation, action } from "./_generated/server";
import { v } from "convex/values";
// QUERY — read-only, reactive, cached, runs against the database
export const getTasks = query({
args: { listId: v.id("lists") },
handler: async (ctx, args) => {
return await ctx.db
.query("tasks")
.withIndex("by_list", (q) => q.eq("listId", args.listId))
.collect();
},
});
// MUTATION — read/write, transactional, also reactive
export const createTask = mutation({
args: { listId: v.id("lists"), text: v.string() },
handler: async (ctx, args) => {
return await ctx.db.insert("tasks", {
listId: args.listId,
text: args.text,
completed: false,
createdAt: Date.now(),
});
},
});
// ACTION — can call external APIs, non-deterministic, NOT reactive
export const summarizeWithAI = action({
args: { taskId: v.id("tasks") },
handler: async (ctx, args) => {
const task = await ctx.runQuery(api.tasks.getTask, { id: args.taskId });
const summary = await callOpenAI(task.text); // external API
await ctx.runMutation(api.tasks.updateSummary, { id: task._id, summary });
return summary;
},
});
The shape — query/mutation/action, an args schema, a handler that takes ctx — is the entire mental model. The runtime enforces the differences:
| Primitive | Reads DB | Writes DB | External HTTP | Reactive | Cached | Transactional |
|---|---|---|---|---|---|---|
query |
✅ | ❌ | ❌ | ✅ | ✅ | snapshot read |
mutation |
✅ | ✅ | ❌ | ✅ (triggers) | ❌ | ✅ ACID |
action |
✅ (via runQuery) |
✅ (via runMutation) |
✅ | ❌ | ❌ | not guaranteed |
A few things to internalize:
- Queries can't call
fetch. They must be deterministic functions of the database. That's why they're cacheable and reactive — the platform can re-run them whenever any of their dependencies change. - Mutations can't call
fetcheither. They run inside an ACID transaction with serializable isolation, so they must complete in a bounded time. This is the same constraint as a Postgres stored procedure. - Actions are the only place where you can do anything else — call OpenAI, hit Stripe, read files. Actions don't run inside a transaction and don't auto-update subscribed queries, so to make their effects visible you typically write a mutation inside the action (the
runMutationpattern above).
This isn't arbitrary. It's the rule that makes the reactive model correct: the only way data changes in the system is via a transaction, so the system knows exactly which queries need to be re-run.
The reactive subscription model (the actual magic)
Here's the part that's hard to appreciate without seeing it. On the client, you use a useQuery hook:
// src/components/TaskList.tsx
import { useQuery, useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
export function TaskList({ listId }: { listId: Id<"lists"> }) {
const tasks = useQuery(api.tasks.getTasks, { listId }); // ← subscribes
const createTask = useMutation(api.tasks.createTask);
if (tasks === undefined) return <p>Loading…</p>;
return (
<ul>
{tasks.map((t) => (
<li key={t._id}>
<input
type="checkbox"
checked={t.completed}
onChange={() => {/* toggle mutation */}}
/>
{t.text}
</li>
))}
<button onClick={() => createTask({ listId, text: "Buy milk" })}>
Add
</button>
</ul>
);
}
That's it. No useEffect, no manual subscription, no socket wiring, no cache invalidation. useQuery(api.tasks.getTasks, { listId }) does three things:
- Sends the query + args to the server on first mount.
- Server runs the query, computes the result, and registers the client as a subscriber to everything the query read — every document, every index range it touched.
- Whenever any document in that dependency set changes, the server re-runs the query and pushes the new result to the client. React re-renders. Latency: typically 50–150 ms end-to-end.
This is the difference between Convex and the alternatives. With Firebase you'd write onSnapshot(query(collection(db, 'tasks')), callback). With Supabase you'd wire up supabase.channel().on('postgres_changes', ...).subscribe() and manually map change events to your client cache. With Convex you write a query and the platform figures out the rest.
The dependency tracking is precise, not coarse. If a query reads tasks in list A, a change to tasks in list B doesn't re-trigger it. The platform traces exactly which documents and ranges were read, builds a dependency set, and invalidates only those subscriptions. That's why it scales to thousands of concurrent subscribers per query without falling over.
Schemas and validators
Convex uses code-defined schemas and per-function argument validators:
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
lists: defineTable({
name: v.string(),
ownerId: v.id("users"),
}).index("by_owner", ["ownerId"]),
tasks: defineTable({
listId: v.id("lists"),
text: v.string(),
completed: v.boolean(),
createdAt: v.number(),
summary: v.optional(v.string()),
})
.index("by_list", ["listId"])
.searchIndex("search_text", { // full-text search
searchField: "text",
filterFields: ["listId"],
})
.vectorIndex("by_embedding", { // vector search for AI features
vectorField: "embedding",
dimensions: 1536,
filterFields: ["listId"],
}),
});
Every argument to every function is validated with v.string(), v.id("tasks"), v.object({...}), etc. — the same convex/values validators you used in the function args. The schema is declarative, type-generated, and end-to-end type-safe: the same _generated/ types are imported on the client, so api.tasks.createTask({ listId: "wrong_type", ... }) is a TypeScript error at compile time and a runtime validation error if you bypass it.
This is the bit that matters for AI-generated code. The pitch from Convex — "agent hallucinations are caught before they ship" — is real: when an LLM writes a query that references a non-existent field, or passes the wrong type for an ID, TypeScript fails the build before it reaches production. With a loosely-typed REST backend, the same bug ships and surfaces at 3 a.m. as a 500.
Mutations, transactions, and the parts Postgres people will recognize
If you've written any Postgres, the mutation model will feel half-familiar:
// Multiple writes in one mutation = one ACID transaction
export const archiveList = mutation({
args: { listId: v.id("lists") },
handler: async (ctx, { listId }) => {
const tasks = await ctx.db
.query("tasks")
.withIndex("by_list", (q) => q.eq("listId", listId))
.collect();
for (const task of tasks) {
await ctx.db.patch(task._id, { archived: true });
}
await ctx.db.patch(listId, { archived: true, archivedAt: Date.now() });
},
});
All the writes in a single mutation run inside a single ACID transaction. If any write fails, the entire mutation rolls back. There's no partial-update state for the client to observe. Concurrent mutations are serialized — you can't lose a write to a race.
You can also implement findOrCreate-style helpers:
export const findOrCreateUser = mutation({
args: { email: v.string() },
handler: async (ctx, { email }) => {
const existing = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", email))
.first();
if (existing) return existing._id;
return await ctx.db.insert("users", {
email,
createdAt: Date.now(),
});
},
});
The pattern — query by indexed field, branch, insert if missing — is the same one you'd write in SQL, but the runtime guarantees no other mutation can insert the same email between your first() and your insert().
Auth, scheduled functions, and HTTP actions
Convex is unopinionated about auth. You bring your own (Clerk, Auth0, custom JWT). The recommended pattern is a users table keyed by whatever your auth provider gives you, and a ctx.auth.getUserIdentity() helper inside functions that pulls the verified identity off the request:
export const createTask = mutation({
args: { listId: v.id("lists"), text: v.string() },
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthenticated");
const list = await ctx.db.get(args.listId);
if (!list || list.ownerId !== identity.subject) {
throw new Error("Forbidden");
}
return await ctx.db.insert("tasks", { ...args, completed: false });
},
});
For background work, you have cron jobs and the scheduler:
// convex/crons.ts
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
crons.interval("purge old sessions", { hours: 1 }, internal.sessions.purge);
crons.cron("nightly digest", "0 7 * * *", internal.digests.send);
export default crons;
And httpAction lets you expose webhook endpoints (Stripe, GitHub, etc.):
import { httpAction } from "./_generated/server";
export const stripeWebhook = httpAction(async (ctx, req) => {
const sig = req.headers.get("stripe-signature");
const event = await stripe.verifyWebhook(req, sig);
await ctx.runMutation(internal.billing.handleEvent, { event });
return new Response(null, { status: 200 });
});
You put it together: a TypeScript-native backend with a real transaction model, reactive subscriptions, file storage, cron, vector search, and webhook handling, all deployable with one command.
Local development workflow
The dev loop is unusually clean:
npm install convex
npx convex dev # watches your convex/ folder, syncs to a dev deployment
npm run dev # your Next.js / Vite / etc. dev server
npx convex dev does three things in parallel: runs a local proxy, syncs your functions and schema to your dev deployment on Convex's cloud, and pushes the generated _generated/api.d.ts to your repo so the client gets the new types immediately. Your IDE updates as soon as you save a function. The whole round trip — save a file → see the type on the client — is one to two seconds.
You get a single dashboard with logs, function traces, data browser, and deployment management for every project:
- Dev deployment — your own sandbox data, regenerated as you iterate
- Preview deployments — one per PR, for end-to-end testing
- Production deployment — what your users hit
Convex vs Supabase vs Firebase — when to pick which
These three get compared constantly because they solve overlapping problems. They have meaningfully different philosophies though:
| Convex | Supabase | Firebase | |
|---|---|---|---|
| Data model | Document (typed) | Relational Postgres | Document (NoSQL) |
| Query language | TypeScript functions | SQL + JS client | JS SDK + listener API |
| Real-time | Reactive queries (automatic) | Postgres LISTEN/NOTIFY via channels |
onSnapshot listeners |
| Transactions | ACID, serializable | ACID, full SQL | Limited, single-doc only |
| Auth | Bring your own | Built-in (GoTrue) | Built-in (Firebase Auth) |
| Storage | Built-in (files) | Built-in (S3-compatible) | Built-in (GCS) |
| Type safety | End-to-end via codegen | Generated from SQL schema | Loose |
| Hosting | Managed + self-host (beta) | Managed + self-host (open source) | Managed only |
| Vector search | Built-in | pgvector extension |
Extension |
| Lock-in | Medium (functions are portable TS, schema is custom) | Low (it's Postgres) | High (Google ecosystem) |
Pick Convex when you're building a real-time-heavy app where the UI updates as data changes (collaborative tools, dashboards, multiplayer games, chat, anything with live cursors), and you want to skip the websocket/caching layer entirely. It's also the strongest choice when AI agents are writing your backend code, because the type-safety story catches their mistakes at build time.
Pick Supabase when you want real SQL — complex joins, window functions, PostGIS, full-text search via tsvector, third-party Postgres extensions, or a relational schema your data team can write queries against. Supabase is also the open-source choice if vendor lock-in matters.
Pick Firebase when you're shipping a mobile app first, want a battle-tested offline cache, and your data is genuinely document-shaped (no joins, no relational integrity). Firebase is the most mature choice for native iOS/Android SDKs.
The honest answer is that any of these will get you to a working app, and the differences show up at scale and in how much backend code you write by hand. Convex asks you to write the least backend code of the three. Supabase asks you to write the most portable code. Firebase asks you to write the most Firebase-specific code.
The trade-offs (the part the marketing doesn't say)
Convex is genuinely good. It's not without costs. Here's the honest breakdown I give teams when they're evaluating it.
Pros
- Reactive updates for free. Once you write a query, the platform tracks its dependencies and pushes updates to every subscribed client. No WebSocket code, no manual cache invalidation, no pub/sub wiring. This is the single biggest productivity win and the reason the platform exists.
- End-to-end TypeScript type safety. Schemas, function arguments, return types, and client calls all share the same generated types. Type errors that would normally surface as production 500s get caught at build time. The pitch "agent hallucinations are caught before they ship" is real — when an LLM writes a query that references a non-existent field, TypeScript fails the build.
- Transactional ACID writes. Mutations run inside serializable ACID transactions. No "eventually consistent" caveats, no lost-update bugs, no partial writes. The
findOrCreatepattern just works without race conditions. - Three primitives, one mental model. Queries, mutations, actions. The shape is identical across all three. There's no separate "events" or "triggers" or "stored procedures" surface to learn — the runtime enforces the differences.
- Local dev loop is fast. Save a file → types regenerated → function deployed → ready to call in ~1.5 seconds. The two-terminal loop (
npx convex dev+ your framework's dev server) is one of the best developer experiences in any backend. - Everything is bundled. Database, functions, real-time sync, scheduler, vector search, full-text search, file storage, HTTP actions, and cron jobs are all first-class. You don't wire a separate Redis, a separate job runner, a separate file storage service, or a separate vector DB.
- No infrastructure to manage. No servers to scale, no Redis to cluster, no Postgres to back up, no websocket gateways to operate. The platform handles scaling, durability, and replication.
- Vector and full-text search built-in. Defining a vector index is one line on the schema. No separate Pinecone account, no separate Meilisearch instance, no ETL pipeline.
- Boilerplate is genuinely low. The minimum viable app is genuinely small — schema + a few functions + one provider component + a hook. The 60-line end-to-end example near the end of this post is the whole point.
- Strong defaults. The platform forces you to do the right thing: deterministic queries, scoped arguments, transactional writes, scoped access via
ctx. You don't need a style guide to keep the codebase consistent.
Cons
- New ecosystem. Stack Overflow answers are thinner, third-party tutorials are fewer, and you'll hit edge cases where the docs are sparse. There's a community on Discord, but the long-tail of "how do I do X with library Y" troubleshooting is shorter than for Postgres or Firebase.
- Pricing scales with function calls and database bandwidth. A reactive app with many subscribed queries can rack up function calls faster than a typical REST app would issue requests. The pricing model is honest, but it's a different shape than "rows read + writes" — instrument early and watch your dashboard.
- Custom data model. Documents are typed and you write functions, not SQL. If your data team has analysts who live in SQL, Convex won't give them a query interface. You'd export to Postgres or BigQuery for analytics workloads.
- No arbitrary code in queries or mutations. External HTTP, AI calls, file system access — anything non-deterministic — has to go through an action. This is the right architectural decision (it's what makes the reactive model correct), but it can feel constraining if you're used to writing "just call
fetch" inside an ORM callback. - Cold starts are real. Function cold-start latency is typically 100–300 ms for the first request after a deployment or a long idle period. Steady-state is 20–50 ms, but for hot interactive flows, warm up critical paths.
- No arbitrary SQL. Cross-document joins are done in TypeScript in your function. Five-table joins across deeply relational data feel heavier than the equivalent SQL. The query API is good for what it's designed for (document lookups, indexed filters, pagination) and not for what it isn't (arbitrary SQL).
- No partial writes across mutations. Each mutation is one transaction. If you want to coordinate writes that should commit together, you put them in one mutation. If you want to coordinate writes that should commit separately — eventual consistency across services — you have to model that explicitly with action +
runMutation. - Vendor lock-in is real. Your functions are TypeScript (portable), but your schema definition, your file storage, your auth helpers, and your reactive client are all Convex-specific. Migrating off would mean rewriting the function layer, not just swapping a database driver.
- Limited offline support. Convex has web offline support and is improving the mobile SDK, but if you need a robust offline-first mobile experience with local conflict resolution, Firebase is still stronger here.
- Less mature observability. Function logs and traces are good, but the dashboard isn't on the level of a mature APM yet. For serious production debugging you'll want to pipe logs to Datadog, Sentry, or similar.
- No raw SQL escape hatch. If you need a one-off analytics query, a
COPYfor bulk export, or apg_dumpfor backup, you don't have it. Convex's built-in export and the dashboard's data browser cover the common cases, but anything exotic requires a pipeline.
None of these are deal-breakers for the apps Convex is built for. They're just the things I wish someone had told me on day one.
When I'd reach for Convex on a real project
After a few projects with it, here's the heuristic I use:
- Reactive UIs (dashboards, live data, collaborative editing, multiplayer, chat, notifications): Convex first. The reactive model is the reason this tool exists, and it's dramatically less work than the alternatives.
- AI-powered apps: Convex first. The vector index, the type safety that catches LLM-generated bugs, the action primitive for LLM calls, and the reactive UI that updates when an AI summary lands all line up.
- CRUD apps with light real-time needs: Either Convex or Supabase. Pick Convex if you want to ship faster; pick Supabase if you want open-source portability.
- Complex relational data, BI, or analytics: Supabase or a real Postgres. Convex's document model doesn't shine when you need five-table joins and window functions.
- Mobile-first with offline cache: Firebase still wins for native mobile offline. Convex has web offline support; the mobile story is maturing.
A small but realistic example end-to-end
To leave you with something concrete, here's a complete task tracker — schema, three functions, and the React component — in about sixty lines:
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
lists: defineTable({
name: v.string(),
ownerId: v.string(),
}),
tasks: defineTable({
listId: v.id("lists"),
text: v.string(),
completed: v.boolean(),
}).index("by_list", ["listId"]),
});
// convex/tasks.ts
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
export const list = query({
args: { listId: v.id("lists") },
handler: async (ctx, { listId }) => {
return await ctx.db
.query("tasks")
.withIndex("by_list", (q) => q.eq("listId", listId))
.collect();
},
});
export const create = mutation({
args: { listId: v.id("lists"), text: v.string() },
handler: async (ctx, { listId, text }) => {
return await ctx.db.insert("tasks", { listId, text, completed: false });
},
});
export const toggle = mutation({
args: { id: v.id("tasks") },
handler: async (ctx, { id }) => {
const task = await ctx.db.get(id);
if (!task) throw new Error("not found");
await ctx.db.patch(id, { completed: !task.completed });
},
});
// src/TaskList.tsx
import { useQuery, useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
import type { Id } from "../convex/_generated/dataModel";
export function TaskList({ listId }: { listId: Id<"lists"> }) {
const tasks = useQuery(api.tasks.list, { listId });
const create = useMutation(api.tasks.create);
const toggle = useMutation(api.tasks.toggle);
if (tasks === undefined) return <p>Loading…</p>;
return (
<div>
<button onClick={() => create({ listId, text: "New task" })}>+ Add</button>
<ul>
{tasks.map((t) => (
<li key={t._id}>
<input
type="checkbox"
checked={t.completed}
onChange={() => toggle({ id: t._id })}
/>
{t.text}
</li>
))}
</ul>
</div>
);
}
Three functions, one component, and every change a user makes propagates to every other connected client. No cache to invalidate, no websocket to wire, no realtime channel to subscribe to. That's the pitch, and after a few projects with it, I've stopped being surprised when it just works.
If your app is real-time-first or AI-assisted, Convex is the rare backend that earns its abstraction. Pick it for the right job and you'll ship in days what used to take weeks of plumbing.