◆ Convex Stack Bootcamp
Day 4 of 7

Day 4 — Actions, scheduling, and crons

The one idea. Determinism is the line. Everything good about queries and mutations — transactions, retries, caching, reactivity — is paid for by not touching the network. Actions cross the line, so they give all of it up. Your job is to keep the crossing small.

Time: ~90 min · Video: The Ultimate Convex Beginner Tutorial — Part 2: Actions (4:58) · You ship: a third-party API call, persisted, running on a schedule.


#1. What an action gives up

export const fetchTitle = action({
  args: { linkId: v.id("links"), url: v.string() },
  handler: async (ctx, args) => {
    const res = await fetch(args.url);
    const html = await res.text();
    const title = html.match(/<title>(.*?)<\/title>/)?.[1] ?? args.url;

    await ctx.runMutation(internal.links.setTitle, {
      linkId: args.linkId,
      title,
    });
  },
});

Constraints, all of which follow from "it can call the network":

That last one looks like an annoyance and is actually the design. It forces every database effect of your action to be a named, validated, individually-testable mutation instead of arbitrary writes interleaved with network calls.

#2. "use node"

By default actions run in Convex's own JavaScript runtime, which has fetch. If you need a Node API or an npm package that depends on one:

"use node";

...at the top of the file. A file with "use node" can contain nothing but actions. Put a query in it and the deploy fails.

Convention that avoids the whole problem:

convex/links.ts         → queries + mutations (incl. internalMutation)
convex/linksActions.ts  → "use node"; actions only, calling back via internal.links.*

#3. The sequential-runQuery trap

The story. You send a runner into a building three times: "get me the team," then "get me the owner," then "get me the member count." Between trips, people move. You come back with three facts that were each true at a different moment and were possibly never true together. You then make a decision as if they were a snapshot.

// ❌ three transactions, three different snapshots
const team      = await ctx.runQuery(internal.teams.getTeam, { teamId });
const teamOwner = await ctx.runQuery(internal.teams.getTeamOwner, { teamId });

// ✅ one transaction, one consistent view
const teamAndOwner = await ctx.runQuery(internal.teams.getTeamAndOwner, { teamId });

Each ctx.runQuery / ctx.runMutation is its own transaction. If you need several facts to be consistent with each other, they must come from one call. Same for writes: if two writes must be atomic, they belong in one mutation, not two runMutations.

#4. Internal functions

An action, a scheduled job, and a cron should all target internal.*:

export const setTitle = internalMutation({
  args: { linkId: v.id("links"), title: v.string() },
  handler: async (ctx, args) => {
    await ctx.db.patch("links", args.linkId, { title: args.title });
  },
});

If this were a public mutation, anyone on the internet could set any link's title to anything. Convex guarantees internal functions are only callable from inside Convex — that guarantee is free, and it's the difference between a private helper and an open endpoint.

#5. Scheduling

await ctx.scheduler.runAfter(0, internal.links.fetchTitle, { linkId, url });
await ctx.scheduler.runAt(dueDate, internal.reminders.send, { id });

runAfter(0, ...) is the workhorse pattern: a mutation commits instantly, and hands the slow, fallible network work to an action that runs just after. The user's click stays fast and transactional; the flaky part happens outside the transaction where it belongs.

// convex/crons.ts
crons.daily(
  "send daily reminder",
  { hourUTC: 17, minuteUTC: 30 },
  internal.messages.sendInternalMessage,
  { author: "System", body: "Share your daily update!" },
);

Note internal. again, and note hourUTC. Crons run in UTC. "9am" is a question about a timezone, and the answer changes twice a year.

#6. Await everything

An unawaited promise in a Convex function can surface as an error in a later, unrelated invocation. That is a genuinely horrible debugging experience: the stack trace points somewhere innocent.

ctx.scheduler.runAfter(0, internal.tasks.process);   // ❌ dangling
await ctx.scheduler.runAfter(0, internal.tasks.process); // ✅

Turn on the no-floating-promises ESLint rule today, not after it bites you.

#Quick check 1

Why does Convex refuse to auto-retry actions when it happily retries mutations?

Answer

A mutation is a deterministic transaction — a failed attempt left nothing behind, so re-running it is always safe. An action may have already had an external side effect (charged a card, sent an email) before failing. Retrying could double it. Convex cannot know which of your side effects are idempotent, so it refuses to guess.

#Quick check 2

A user clicks "save link." You want to store it immediately and also fetch the page title from the web. Design it.

Answer

A mutation inserts the link (fast, transactional, UI updates instantly) and ends with await ctx.scheduler.runAfter(0, internal.links.fetchTitle, { linkId, url }). The action does the fetch and writes back via an internalMutation. The user never waits on a third-party server, and a dead URL cannot roll back their save.

#Quick check 3

Your action reads a user's balance, calls a payment API, then writes the new balance — using three separate ctx.runQuery/ctx.runMutation calls. What's the bug?

Answer

The read and the write are in different transactions with a network call between them — a textbook lost update. Two concurrent runs can both read 100, both charge, and both write 50. The balance change must happen inside one mutation that re-reads and conditionally writes, ideally keyed on an idempotency token from the payment provider.


#Build task

  1. links.create (mutation) inserts, then ctx.scheduler.runAfter(0, ...) an action.
  2. The action fetches the URL, extracts <title>, writes back via internalMutation.
  3. Watch the UI: the row appears with a bare URL, then the title fills in a moment later — with no refetch code. That is Day 3's reactivity meeting Day 4's async work.
  4. Point it at a URL that 404s. Confirm the link still saved.
  5. Add a daily cron that archives links older than 30 days. Target internal.*.
  6. Deliberate bug: make the action do two sequential ctx.runMutation calls where the second throws. Confirm the first one persisted. Write down why.

#Checkpoint

  1. Name three things an action cannot do, and the single property that explains all three.
  2. What breaks if you put a query in a "use node" file?
  3. Why must several facts an action needs come from one runQuery rather than three?
  4. Why internal.* and never api.* for scheduled work?
  5. What's the failure mode of an unawaited promise, and why is it hard to debug?

#Cheat strip

action({ args, handler })   net ✅ · txn ❌ · retry ❌ · reactive ❌ · ctx.db ❌
  → ctx.runQuery(internal.x.y, args) / ctx.runMutation(internal.x.y, args)
"use node"  → actions ONLY in that file
limits      : 10 min · 64MB (convex) / 512MB (node) · 1000 concurrent

scheduler : ctx.scheduler.runAfter(ms, internal.x.y, args) · runAt(ts, ...)
crons     : crons.daily("name", {hourUTC, minuteUTC}, internal.x.y, args)   ← UTC!
pattern   : fast transactional mutation → runAfter(0) → slow fallible action
rule      : one runQuery per consistent snapshot · await everything

#Tomorrow

Day 5: every public function you have written so far is an open endpoint on the internet. We fix that.

Stored in this browser only.