convex-cron-jobs
Skill404 starsChanged 48 days agoApache-2.0View on GitHub
Schedules work in Convex: cron jobs in convex/crons.ts, one off scheduled functions with runAfter and runAt, batching large jobs, and cancelling or inspecting the queue. Use when something needs to run on a timer, later, or in the background, or when a cron is not firing.
2 other projects use a version of this.
Adopted by
Most recently taken up by
- waynesutton/convexskillsword for word, first seen Sep 28, 2026
- StillScripts/cursor-agents-managertheir own version, first seen Jan 17, 2026
Other versions2 projects use a version of this.
Word for word · 1
- waynesutton/convexskills★ 405100% same
Edited · 0
None found.
Wrote their own version · 1
- StillScripts/cursor-agents-manager★ 1same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository19 other files it gives its agents.
- AGENTS.mdAGENTS.md
- templates/AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- avoid-feature-creepSkill
- convex-agentsSkill
- convex-best-practicesSkill
- convex-component-authoringSkill
- convex-file-storageSkill
- convex-functionsSkill
- convex-http-actionsSkill
- convex-migrationsSkill
- convex-realtimeSkill
- convex-schema-validatorSkill
- convex-security-auditSkill
- convex-security-checkSkill
- convexSkill
- git-safetySkill
- project-docsSkill
- project-workflowSkill
The file
We split this skill into the rules it gives an agent
2 rules. About 0 are common, 1 is distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 51
Import internal from ./_generated/api, even when the target is defined in crons.ts.
- Distinctive: line 148
Check Logs for a thrown error.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Convex cron jobs and scheduling
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.*.
When to reach for this
- Something needs to run every N minutes or at a fixed time of day
- A mutation needs to kick off follow up work after it commits
- A job touches more rows than one mutation should handle
- A cron shows in the dashboard but never runs, or runs at the wrong hour
- A pending job needs to be cancelled, debounced, or inspected
Deeper material lives in two reference files:
- references/scheduling-patterns.md: open when you need retry with backoff, cancelling a job, reading
_scheduled_functions, debouncing, or local time zones. - references/cron-recipes.md: open for short copyable jobs: daily digest email, session expiry, stats rollup, external API sync.
crons.ts skeleton
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:
- Names are unique within the file. The dashboard lists jobs by this name.
- Import
internalfrom./_generated/api, even when the target is defined incrons.ts. - Args are static and must satisfy the target function's
argsvalidator. - Interval units are
{ 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 |
Targets are internal functions
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.
One batched job
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.
runAfter vs runAt
// 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:
- Scheduling inside a mutation is transactional. If the mutation throws, nothing is scheduled. Scheduling inside an action happens right away, even if the action fails later.
- Scheduled mutations run exactly once. Scheduled actions may fail without retry, so add retry logic to actions or use a retry component.
Seeing runs in the dashboard
- Schedules, Cron Jobs tab: every entry from
crons.ts, with last run and next run. - Schedules, Scheduled Functions tab: pending
runAfterandrunAtjobs. - Logs, filtered by function name: each execution, its duration, and any thrown error.
- From the CLI:
npx convex logsstreams the same log lines. - From code:
ctx.db.system.get(jobId)returns the job document withstate.kindset topending,inProgress,success,failed, orcanceled.
When a cron is not firing
- The file is exactly
convex/crons.tsand ends withexport default crons. npx convex devis running and the last push succeeded. Cron changes only apply on push.- The target is
internal.*and the args match its validator. A mismatch fails at push time. - The expression is UTC. Convert your local hour before comparing.
- Check Logs for a thrown error. A job that throws every run looks like a job that never runs.
Common mistakes
| 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 |
Checklist
convex/crons.tsuses onlycrons.intervalandcrons.cron, ends withexport default crons- Every cron and scheduler target is
internal.* - Every target has
argsandreturnsvalidators - Jobs that touch many rows take a batch and reschedule with
runAfter(0, ...) - Range conditions live in
withIndex, not.filter - No
Date.now()inside queries - Every
ctx.scheduler.*call is awaited - Cron hours are written in UTC
- Job ids are stored on documents when cancel or debounce is needed
- Ran
npx convex devand saw the job listed under Schedules
Docs
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

