Beyond CRUD
Actions & scheduling
Lesson 2 of 3
What you'll learn
- Know when to use an action vs. a mutation
- Call a mutation from an action to persist results
- Schedule work to run later with
runAfterand crons
Queries and mutations are transactional and deterministic — they can only touch the database. That's a feature: it's what makes them fast and consistent. But it means they can't call an external API, send an email, or use randomness. For that you use an action.
export const summarize = action({
args: { postId: v.id("posts") },
handler: async (ctx, { postId }) => {
const post = await ctx.runQuery(api.posts.get, { postId });
const summary = await callLLM(post.body); // external call — allowed in actions
await ctx.runMutation(internal.posts.saveSummary, { postId, summary });
},
});
Actions can't write to the database directly. They orchestrate: read with ctx.runQuery, do the side-effect, then persist with ctx.runMutation.
Scheduling
You rarely want to block a user while a slow action runs. Schedule it instead — from any mutation or action:
await ctx.scheduler.runAfter(0, internal.posts.summarize, { postId });
runAfter(0, …) runs it right after the current function commits — durable and transactional. For recurring work, define a cron in convex/crons.ts:
crons.interval("daily digest", { hours: 24 }, internal.email.sendDigest, {});
Crons call internal functions
Scheduled and cron jobs should target internal.* functions, not public ones — there's no client to authenticate them, so they must not be part of your public API.
The challenge models a job queue: schedule tasks, then run the due ones. Run it.
Tasks are queued with a delay, then the runner executes the ones that are due. Run it.
Next: making sure each function only does what the caller is allowed to do.
Sign in to save your progress across devices.