BuildBot

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 runAfter and 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.

A tiny scheduler

Tasks are queued with a delay, then the runner executes the ones that are due. Run it.

Loading editor…

Next: making sure each function only does what the caller is allowed to do.

Sign in to save your progress across devices.