Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild
Back to Blog Explainer

Convex API: Queries, Mutations, and Actions - and Why the Split Exists

Sean

Platform Writer

Aug 27, 2026
7 min read

Convex has three function types and they are not interchangeable. Queries read and are cached and reactive, mutations write and are transactional, and actions can call the outside world and have neither property.

Convex API: Queries, Mutations, and Actions - and Why the Split Exists

Most confusion with Convex resolves into one question: why can I not call a third-party API from here? The answer is in the split, and the split exists because caching, transactions, and network calls are properties that cannot all coexist in one function type.

Table of contents

The three types

The distinction is best held as a table, because each capability is a deliberate trade.

  • Queries - read the database. Transactional, cached, and reactive: subscribed clients update automatically when underlying data changes. Cannot write, cannot call external APIs.
  • Mutations - write the database. Transactional: the whole function commits or none of it does. Not cached, not reactive. Cannot call external APIs.
  • Actions - can call external APIs. No database access of their own, no transaction, no caching. They call queries and mutations to touch data.

The reason queries cannot write is what makes them cacheable and reactive. A pure function of the database state can be cached safely and re-evaluated when its inputs change. Allow it to write and both properties collapse - the cache is invalid and the reactivity becomes a loop.

The reason mutations cannot call external APIs is transactions. A mutation runs as an atomic unit, and it can be retried. A retried function that already charged a card has charged it twice. Network calls are not transactional and cannot be rolled back, so they are excluded.

Actions exist precisely because the outside world is not transactional. They get network access and give up the guarantees, which is the honest trade rather than a limitation.

Queries and reactivity

A query is a function of the database. Subscribe from a client and it re-runs when the data it read changes.

// convex/messages.ts
import { query } from './_generated/server'
import { v } from 'convex/values'

export const list = query({
  args: { channelId: v.id('channels') },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('messages')
      .withIndex('by_channel', q => q.eq('channelId', args.channelId))
      .order('desc')
      .take(50)
  },
})
// In a React component
import { useQuery } from 'convex/react'
import { api } from '../convex/_generated/api'

function Messages({ channelId }) {
  const messages = useQuery(api.messages.list, { channelId })
  if (messages === undefined) return <Spinner />
  return <ul>{messages.map(m => <li key={m._id}>{m.body}</li>)}</ul>
}

There is no polling, no websocket code, and no cache invalidation to manage. When a mutation writes a message into that channel, every subscribed client re-renders. That is the actual selling point of the framework, and it is a genuinely large amount of code you do not write.

Note the undefined check - it means loading, distinct from an empty array meaning no results. Conflating the two produces a flash of an empty state on every load.

The index usage matters as much as it does in any database. A query without an index scans, and the reactivity machinery has to track what it read - a query that reads the whole table gets invalidated by every write to it, which turns reactivity from an asset into a performance problem.

Mutations and transactions

A mutation is atomic. Every write inside it commits together or none of them do.

import { mutation } from './_generated/server'
import { v } from 'convex/values'

export const send = mutation({
  args: { channelId: v.id('channels'), body: v.string() },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) throw new Error('Not authenticated')

    const messageId = await ctx.db.insert('messages', {
      channelId: args.channelId,
      body: args.body,
      author: identity.subject,
    })

    // Both writes commit together, or neither does
    await ctx.db.patch(args.channelId, { lastMessageAt: Date.now() })

    return messageId
  },
})

That atomicity is worth more than it looks. In a conventional stack, inserting a message and updating the channel timestamp are two operations that can partially fail, and handling that means either an explicit transaction or accepting occasional inconsistency. Here it is the default.

The argument validators are not decoration. They run at the boundary and reject malformed input before your handler sees it, and they generate the types the client uses - so a client calling with the wrong shape is a compile error rather than a runtime one.

Mutations may return a value or nothing. Returning the identifier of what was created is usually worth it, because the caller frequently needs it and fetching it separately is a second round trip.

Actions and the outside world

Anything involving a third-party API, a model call, an email, or a payment goes in an action.

import { action } from './_generated/server'
import { api } from './_generated/api'
import { v } from 'convex/values'

export const summariseChannel = action({
  args: { channelId: v.id('channels') },
  handler: async (ctx, args) => {
    // Read via a query - actions have no direct db access
    const messages = await ctx.runQuery(api.messages.list, {
      channelId: args.channelId,
    })

    const res = await fetch('https://api.example.com/v1/summarise', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text: messages.map(m => m.body).join('\n') }),
    })
    const { summary } = await res.json()

    // Write via a mutation
    await ctx.runMutation(api.channels.setSummary, {
      channelId: args.channelId,
      summary,
    })
  },
})

The structure is always the same: read through a query, do the external work, write through a mutation. Actions orchestrate; they do not touch data directly.

The critical property to internalise: an action is not atomic. If the fetch succeeds and the mutation then fails, you have called the external service and recorded nothing. For anything with side effects that matter - charges, emails, provisioning - design for that. Use idempotency keys with the provider, and record the attempt before making the call so a retry can detect it.

Actions run in a different environment from queries and mutations, with access to Node APIs when you request it. That is what makes libraries requiring Node built-ins usable, and it is a per-file declaration rather than a global setting.

What you are choosing when you choose this

Convex is a managed platform. Your functions run on their infrastructure, your data lives in their database, and the reactivity is a property of that system rather than something portable.

What you get is real: no API layer to write, no cache invalidation, no websocket plumbing, end-to-end types, and transactions by default. For a collaborative application - anything where multiple people see the same changing state - that removes a genuinely large amount of the hardest code.

What you accept is that the reactive model is the product. The functions are TypeScript and portable in the trivial sense, but the thing that made them worth writing is the platform. Moving off means rebuilding the data layer and reimplementing reactivity yourself, which is a substantially different project from swapping one database for another.

That trade is worth making deliberately rather than by default. If your application’s defining characteristic is shared live state, the leverage is high and the lock-in is a fair price. If it is a conventional request-response application that happens to need a database, a service and a managed Postgres give you a stack where every piece is replaceable - and on RunxBuild that is a repository push with a build log, a live route, runtime logs, and rollback beside a managed database with backups and connection limits.

How this fits the rest of the stack

Choosing a reactive platform is a judgement about how much of your application is live shared state, and it is worth pricing the conventional alternative before deciding. The RunxBuild hosting calculator shows that shape as separate line items - a service, a managed Postgres or MySQL, storage, and bandwidth - so the comparison is between two known costs rather than between a bill and an assumption.

Useful related references:

FAQ

What is the difference between a Convex query, mutation, and action?

Queries read the database and are transactional, cached, and reactive. Mutations write and are transactional but not cached or reactive. Actions can call external APIs but have no direct database access, no transaction, and no caching - they call queries and mutations to touch data.

Why can’t I call an external API from a Convex mutation?

Because mutations are transactional and can be retried. A retried function that already called a payment provider has charged twice, and a network call cannot be rolled back. External calls go in actions, which give up transactional guarantees in exchange for network access.

How does Convex reactivity work?

Queries are pure functions of database state, so the platform can track what each one read and re-run it when that data changes. Subscribed clients update automatically with no polling, websocket code, or cache invalidation on your part. That property is why queries are forbidden from writing.

Are Convex actions atomic?

No. If an external call succeeds and the follow-up mutation fails, the side effect happened and nothing was recorded. For operations that matter - charges, emails, provisioning - use idempotency keys with the provider and record the attempt before making the call so a retry can detect it.

What is the lock-in risk with Convex?

The functions are TypeScript and portable in a trivial sense, but the reactivity that makes them worth writing is a property of the platform. Moving off means rebuilding the data layer and reimplementing reactivity yourself, which is a larger project than swapping one database for another.

#convex api#convex functions#reactive database#backend as a service#typescript