We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide12 min read

Convex, a backend where data refreshes itself

Convex combines a database, server functions, and realtime sync. Queries, mutations, actions, schema, pricing from 25 dollars, and a Supabase comparison.

Convex, a backend where data refreshes itself

A typical web application spends half its code keeping what the user sees in step with what is in the database. Query, cache, invalidate after a write, refetch, handle the loading state. Convex removes that layer entirely, because a query knows for itself when its result stopped being current.

You write the whole backend in TypeScript, and the database, server functions, scheduling, search, and file storage are parts of the same platform. Types travel from server to client with no generation step and no separate contract.

What reactivity means here

In the classic arrangement a query is a one off. You ask for a task list, get an answer, and from that moment hold stale data, which you learn about only on the next fetch.

Here a query is a subscription. The platform records which documents a function touched, and when one of them changes it pushes a new result to every client watching it. There is no manual refetching, no cache invalidation, and no websocket to wire up yourself.

Code
TypeScript
export const taskList = query({
  args: { project: v.id('projects') },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('tasks')
      .withIndex('by_project', (q) => q.eq('project', args.project))
      .collect()
  }
})

On the React side it looks like an ordinary hook, except it never returns stale data.

Code
TypeScript
const tasks = useQuery(api.tasks.taskList, { project: id })

if (tasks === undefined) return <Loading />
return <List items={tasks} />

An undefined value means loading here rather than an empty result. That is the only state you must handle, since network errors and reconnection are the platform's job.

Three kinds of function

The split into three function types is the heart of the model and the most common source of early confusion.

A query only reads and must be deterministic. It may not call an external API or read the current time, since the platform must be able to reproduce the result on a rerun.

A mutation writes and runs in a transaction. It either completes entirely or not at all, and on conflict it retries automatically. It must be deterministic too.

An action may do anything, including calling an external service, but has no direct database access. It writes by invoking a mutation.

Code
TypeScript
export const sendSummary = action({
  args: { ticket: v.id('tickets') },
  handler: async (ctx, args) => {
    const text = await ctx.runQuery(api.tickets.get, args)
    const answer = await callModel(text)
    await ctx.runMutation(api.tickets.saveSummary, {
      ticket: args.ticket,
      summary: answer
    })
  }
})

That split looks like a restriction and is the source of most of the benefits. Deterministic queries let the platform know when a result changed. Transactional mutations remove an entire class of race conditions people normally fight by hand.

Schema and types

You describe the schema in TypeScript, and document types follow from it automatically, reaching components with no file generation.

Code
TypeScript
export default defineSchema({
  tasks: defineTable({
    title: v.string(),
    project: v.id('projects'),
    done: v.boolean(),
    dueAt: v.optional(v.number())
  }).index('by_project', ['project'])
})

An index is not an optional optimisation here. A query without one scans the whole table, so it works at a thousand documents and starts costing both time and money at a hundred thousand, since billing covers data read.

Note too that optional fields must be marked explicitly. Adding a field to a table holding existing documents without marking it optional rejects the deployment, and rightly so, since old documents would not satisfy the schema.

Background work and scheduling

Things meant to happen later or on a cycle are part of the platform, so you need neither a separate queue nor an external scheduler.

Code
TypeScript
export const createTicket = mutation({
  args: { text: v.string() },
  handler: async (ctx, args) => {
    const id = await ctx.db.insert('tickets', { text: args.text })
    await ctx.scheduler.runAfter(0, api.tickets.sendSummary, {
      ticket: id
    })
    return id
  }
})

Scheduling at zero seconds is a common pattern here. The mutation finishes immediately, the user sees the write, and the model call happens off the response path. That is how long operations stay out of the way of the interface.

Recurring jobs are declared in a separate configuration file naming the frequency and the function to call. It suits clearing old records, sending reports, and synchronising with external systems.

Authentication and permissions

User identity reaches functions through the context, so you need not pass it from the client, where it would not be trustworthy anyway.

Code
TypeScript
export const myTasks = query({
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) return []

    return await ctx.db
      .query('tasks')
      .withIndex('by_owner', (q) => q.eq('owner', identity.subject))
      .collect()
  }
})

The rule is simple and worth adopting from day one: no function trusts its arguments about who the caller is. Take the user identifier from the context rather than from a parameter, since a parameter can be swapped in the browser.

The platform has no separate security rule layer running alongside the code. You enforce permissions in functions, which some count as a drawback against solutions with declarative rules, and which carries an advantage: access logic sits in the same place and language as everything else, so testing it is easier.

Integration with external identity providers, Clerk or Auth0 for instance, comes down to configuration, after which tokens from those services are verified on the function side.

Files and search

File storage is part of the platform, so you need neither separate object storage nor URL signing of your own. The client asks a function for an upload URL, sends the file directly, and you store the returned identifier in the database.

That arrangement matters for cost and performance. The file does not pass through your function, so you neither pay for transfer twice nor risk a timeout on a large upload.

Full text search is declared as an index on a table and queried like an ordinary query. For simple cases, such as searching by task title, it is entirely sufficient and saves a whole separate service.

For semantic search you will still reach for a vector database, Chroma for instance. The platform offers a vector index too, but at large volume with demanding filters a specialised tool gives more room.

Pricing

PlanCostWhat it covers
Starter0 USDPersonal project, limits fine for learning and prototypes
Professional25 USD per developer monthly50 GB data, 25M function calls, support
Businessfrom 2,500 USD monthlyHigher guarantees, compliance needs, priority support

Billing per person rather than per traffic is the distinguishing feature. A team of three pays 75 dollars regardless of how many users the application serves, as long as it stays within plan limits.

Know what happens past those limits, though, because the per person model then stops being the whole truth about the bill. Above the threshold you pay twenty cents for each additional gigabyte of data, three cents per gigabyte of files, twelve cents per gigabyte of egress, and two dollars for every further million function calls. On an application with real time sync that last one accrues faster than intuition suggests, since every subscription refreshed on a data change is a call.

Overages are billed separately: per gigabyte of data, per egress, and per million function calls. That last item surprises people in a reactive application, since every data change recomputes the queries touching it. An application with a list refreshed for a hundred people at once generates a hundred calls on every write.

The practical conclusion is to design queries narrowly. A query returning a whole table recomputes on any row change, while a query indexed by project recomputes only on a change within that project.

Convex against the alternatives

ToolStrengthWeaknessPick it when
ConvexReactivity with no configuration, types from database to viewIts own query model instead of SQLRealtime collaboration application
SupabasePostgreSQL, familiar SQL, opennessReactivity needs handling yourselfProject built on relational data and SQL
FirebaseMaturity, mobile ecosystemHard complex queries, vendor lock inMobile app with a simple data model
CloudflareProximity to users, low cost at scaleMore work on complex dataThings needing to run close to the network edge

The main question is whether you want SQL. If so, the choice lands elsewhere, since queries here are written in TypeScript against a bespoke interface. If instead syncing state between clients matters most, this model saves weeks of work that other options cannot avoid.

The second question is how much vendor lock in bothers you. Function code is ordinary TypeScript, so the logic moves without much pain, but the data model and the subscription mechanism do not. Rewriting the application onto another platform means writing from scratch the layer that here comes free.

Data migrations and teamwork

Changing a schema in a running application is where every platform shows its character. Here a deployment with an incompatible schema is rejected, so you cannot ship a change leaving some documents outside the model.

The standard path has three steps. First you add the field as optional and deploy. Then you run a migration function filling it in existing documents. Only then do you mark the field required and deploy again.

That rhythm irritates during fast prototyping, but it saves you from a state where half the records carry a field and half do not, with nobody noticing until an error in production.

Teamwork rests on separate deployments per developer. Each person has their own environment with their own data, so a schema change by one does not break the others' work. The production environment is separate and updates on deployment from the main branch.

Watch one thing: test data in a development environment tends to drift from reality. A function seeding the database with sample data, kept in the repository, saves hours when onboarding somebody new to the project.

When it is the wrong choice

An application built on complex analytical queries with aggregations and joins will fight the tool. The data model is document oriented and the query interface deliberately simple, so things natural in SQL need workarounds.

The same goes for a project that must run on your own infrastructure without an external service. An open source server exists, but the default path runs through the managed service and that is where development focuses.

The third case is an application with no realtime element. A blog, a shop, or a company site does not need a subscription on every query, and an ordinary database with a cache comes out cheaper and simpler.

The fourth is a team working mainly in a language other than TypeScript. The whole value here comes from one type travelling from the database to the view, and with a backend in Python or Go that argument disappears, leaving an ordinary service with its own interface.

A good test is asking how many screens in the application have to show changes without a page refresh. If the answer is "most of them", the reactive model pays off. If it is "one or two", handling those two screens separately is simpler than building the whole application around that assumption.

Common mistakes

The first is queries without an index. They work at prototype stage and start hurting exactly when the application has users, since a full table scan grows along with it.

The second is calling external services from a query or a mutation. The platform will not allow it, and an attempt to work around it usually means the operation belongs in an action.

The third is broad queries refreshed for everyone. One query returning a full list for every user multiplies function calls on every change and lands directly on the bill.

The fourth is keeping secrets on the client side. Keys to external services belong in environment variables on the function side, not in code shipped to the browser.

The fifth is not handling the loading state. A query returns undefined before the first result arrives, and a component reading a field from that value breaks on first render.

FAQ

Does Convex replace a database?

Yes, it is a database, a server function layer, and a sync mechanism in one. You do not connect PostgreSQL to it; you store data in it directly and write queries in TypeScript rather than SQL.

Convex or Supabase?

Choose Supabase when you want PostgreSQL, SQL, and full control over a relational model. Choose Convex when syncing state between clients matters most and you want one language from database to interface, with no refresh layer to write.

What does it cost on a small project?

The free plan suffices for learning, a prototype, and a modest application. The next step is 25 dollars a month per developer, independent of application user count, as long as you stay within data and function call limits.

Does it work with Next.js?

Yes, the integration covers both client components with subscriptions and server side fetching in Next.js. In practice reactivity earns its keep where data actually changes, while static pages render normally.

Can it be self hosted?

The server code is publicly available and can be run yourself, though support, updates, and operational tooling focus on the managed version. Under a requirement for full independence, check that before deciding.

Documentation sits on the project site, and the server code in the GitHub repository.