Saikat
← Back to blog

What Is Convex? The Reactive Backend Platform Explained

July 24, 2026·16 min read
ConvexBackendRealtimeTypeScriptBaaS
Share:
What Is Convex? The Reactive Backend Platform Explained

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:

  1. A document database with ACID transactions and indexed queries
  2. Server functions — queries, mutations, and actions — written in TypeScript
  3. A reactive sync engine that pushes query results to every subscribed client the instant the underlying data changes
  4. A workflow / scheduler for cron jobs and durable background work
  5. Vector and full-text search
  6. 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 fetch either. 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 runMutation pattern 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:

  1. Sends the query + args to the server on first mount.
  2. 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.
  3. 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.

  • It's a new ecosystem. Stack Overflow answers are thinner, third-party tutorials are fewer, and you'll hit edge cases where the docs are sparse. For a team that's never used Convex before, plan for a learning week.
  • Pricing scales with "function calls + database bandwidth," which can surprise teams used to "rows read + writes." A reactive app with many subscribed queries can rack up function calls faster than a typical REST app would issue requests. Read the pricing page and instrument early.
  • The data model is custom. 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.
  • You can't run arbitrary code inside queries or mutations. Anything external (HTTP, AI, file system) 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 fast, but if you're building a hot interactive app, warm up critical paths.

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.