Scheduled function patterns for background tasks including interval scheduling, cron expressions, job monitoring, retry strategies, and best practices for long-running tasks
Recurring work lives in convex/crons.ts. One off work is scheduled from inside a function with ctx.scheduler. Both must target internal.* functions, never api.*.
Deeper material lives in two reference files:
_scheduled_functions, debouncing, or local time zones.One file, one default export. crons.interval for "every N", crons.cron for calendar times. crons.daily, crons.hourly, and crons.weekly are deprecated helpers. Do not use them.
// convex/crons.ts
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
// Every hour
crons.interval(
"expire sessions",
{ hours: 1 },
internal.sessions.expireBatch,
{},
);
// Every day at 09:00 UTC. Cron expressions are always UTC.
crons.cron("daily digest", "0 9 * * *", internal.digest.send, {});
export default crons;
Rules for every entry:
internal from ./_generated/api, even when the target is defined in crons.ts.args validator.{ seconds }, { minutes }, or { hours }.Cron expression quick reference (minute hour day-of-month month day-of-week):
| Expression | Runs |
|---|---|
*/15 * * * * |
every 15 minutes |
0 * * * * |
every hour at :00 |
0 0 * * * |
daily at 00:00 UTC |
0 8 * * 1 |
Mondays at 08:00 UTC |
0 0 1 * * |
first of each month |
0 9-17 * * 1-5 |
hourly, 09:00 to 17:00 UTC, weekdays |
Public functions expect a client, an auth identity, and untrusted input. Cron and scheduler calls have none of that. A public target skips the auth checks you wrote and exposes the job to anyone who can reach the deployment. Register targets with internalMutation, internalAction, or internalQuery.
A mutation is one transaction with read and write limits. Deleting fifty thousand rows in a loop hits them. Take a fixed slice, reschedule yourself with runAfter(0, ...), and let the chain finish on its own.
// convex/sessions.ts
import { internalMutation } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
const BATCH = 100;
export const expireBatch = internalMutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
// Date.now() is fine in a mutation. Never call it in a query.
const now = Date.now();
const expired = await ctx.db
.query("sessions")
.withIndex("by_expiresAt", (q) => q.lt("expiresAt", now))
.take(BATCH);
await Promise.all(expired.map((s) => ctx.db.delete(s._id)));
// A full batch means more may remain. Chain the next one.
if (expired.length === BATCH) {
await ctx.scheduler.runAfter(0, internal.sessions.expireBatch, {});
}
return null;
},
});
This shape works because each delete removes the row from the index range. If the job updates rows without moving them out of the range, use a pagination cursor instead. See the reference file.
// Relative: 5 minutes from now
const jobId = await ctx.scheduler.runAfter(
5 * 60 * 1000,
internal.reminders.send,
{ taskId: args.taskId },
);
// Absolute: a timestamp you already store (ms since epoch or a Date)
await ctx.scheduler.runAt(trial.endsAt, internal.billing.endTrial, {
userId: trial.userId,
});
| Method | Use for |
|---|---|
runAfter(delayMs, fn, args) |
retries, follow ups, "in ten minutes" |
runAt(timestamp, fn, args) |
trial ends, send dates, anything with a stored time |
Both return an Id<"_scheduled_functions">. Store it on a document if you may need to cancel.
Two behaviors to remember:
crons.ts, with last run and next run.runAfter and runAt jobs.npx convex logs streams the same log lines.ctx.db.system.get(jobId) returns the job document with state.kind set to pending, inProgress, success, failed, or canceled.convex/crons.ts and ends with export default crons.npx convex dev is running and the last push succeeded. Cron changes only apply on push.internal.* and the args match its validator. A mismatch fails at push time.| Mistake | Why it breaks | Do instead |
|---|---|---|
crons.daily(...) |
deprecated helper | crons.cron("...", "0 0 * * *", ...) |
Target is api.tasks.cleanup |
skips auth, publicly callable | register as internalMutation, use internal.tasks.cleanup |
.collect() then loop over thousands |
hits transaction limits | take(BATCH) and reschedule |
.withIndex("by_x").filter(...) |
filter scans the whole index | put the range in withIndex |
Date.now() in an internalQuery |
breaks caching and reactivity | pass now as an arg from the caller |
Cron at "0 9 * * *" for 9am Pacific |
runs at 9am UTC | use UTC, or run hourly and check local hour |
Missing await on runAfter |
job may not be scheduled | always await ctx.scheduler.* |
| Two crons with the same name | push fails | unique names per file |
convex/crons.ts uses only crons.interval and crons.cron, ends with export default cronsinternal.*args and returns validatorsrunAfter(0, ...)withIndex, not .filterDate.now() inside queriesctx.scheduler.* call is awaitednpx convex dev and saw the job listed under Schedules