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.
#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":
- Not transactional. The
fetchsucceeded and therunMutationfailed? The fetch still happened. There is no undo. - Not automatically retried. The docs are explicit: actions "may have side-effects and therefore can't be automatically retried." Retrying a charge is worse than not retrying it.
- Not reactive. Nothing subscribes to an action.
- 10 minute timeout, 64 MB memory in the Convex runtime, 512 MB under Node, up to 1000 concurrent.
- No direct database access.
ctx.dbis not there. You go throughctx.runQuery/ctx.runMutation.
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 thefetchand writes back via aninternalMutation. 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.runMutationcalls. 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
links.create(mutation) inserts, thenctx.scheduler.runAfter(0, ...)an action.- The action fetches the URL, extracts
<title>, writes back viainternalMutation. - 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.
- Point it at a URL that 404s. Confirm the link still saved.
- Add a daily cron that archives links older than 30 days. Target
internal.*. - Deliberate bug: make the action do two sequential
ctx.runMutationcalls where the second throws. Confirm the first one persisted. Write down why.
#Checkpoint
- Name three things an action cannot do, and the single property that explains all three.
- What breaks if you put a query in a
"use node"file? - Why must several facts an action needs come from one
runQueryrather than three? - Why
internal.*and neverapi.*for scheduled work? - 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.