Convex: query, mutation, action — the real difference
It took me a while to really get this. Not the concept — the concept is simple. But why the rules exist.
Here's how I think about it now.
A query is a window. It looks. It doesn't touch anything. You can open it 50 times at once, no problem. And Convex updates it automatically when the data changes — that's the reactive part.
A mutation is a transaction. It writes to the database. All or nothing. If something crashes halfway through, nothing gets saved. Safe by design.
An action is the outside world. It can call an external API — Moneroo, Resend, whatever. In exchange: no direct access to the database.
The rule that follows from this, and one I wish I'd read on day one:
Never call an external API inside a mutation.
If it crashes midway — network drop, timeout — you end up with an order created in the database but no payment initiated. Or the reverse.
The right pattern: mutation → action → mutation.
// The mutation creates the order and delegates the rest
export const createOrder = mutation({
handler: async (ctx, args) => {
const orderId = await ctx.db.insert("orders", { ...args, status: "pending" });
await ctx.scheduler.runAfter(0, internal.payments.initiate, { orderId });
return orderId;
}
});
// The action talks to Moneroo, then mutates again
export const initiate = internalAction({
handler: async (ctx, { orderId }) => {
const link = await moneroo.payments.initialize({ ... });
await ctx.runMutation(internal.orders.setPaymentLink, { orderId, link });
}
});Mutation creates. Action calls out. Mutation confirms. Each step does exactly one thing.
It's more code. But every failure becomes manageable — you know exactly where it broke.