Agent Skills: Convex cron jobs and scheduling

Scheduled function patterns for background tasks including interval scheduling, cron expressions, job monitoring, retry strategies, and best practices for long-running tasks

UncategorizedID: waynesutton/convexskills/convex-cron-jobs

Install this agent skill to your local

pnpm dlx add-skill https://github.com/waynesutton/convexskills/tree/HEAD/skills/convex-cron-jobs

Skill Files

Browse the full folder contents for convex-cron-jobs.

Download Skill

Loading file tree…

skills/convex-cron-jobs/SKILL.md

Skill Metadata

Name
convex-cron-jobs
Description
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.

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:

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 internal from ./_generated/api, even when the target is defined in crons.ts.
  • Args are static and must satisfy the target function's args validator.
  • 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 runAfter and runAt jobs.
  • Logs, filtered by function name: each execution, its duration, and any thrown error.
  • From the CLI: npx convex logs streams the same log lines.
  • From code: ctx.db.system.get(jobId) returns the job document with state.kind set to pending, inProgress, success, failed, or canceled.

When a cron is not firing

  1. The file is exactly convex/crons.ts and ends with export default crons.
  2. npx convex dev is running and the last push succeeded. Cron changes only apply on push.
  3. The target is internal.* and the args match its validator. A mismatch fails at push time.
  4. The expression is UTC. Convert your local hour before comparing.
  5. 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.ts uses only crons.interval and crons.cron, ends with export default crons
  • [ ] Every cron and scheduler target is internal.*
  • [ ] Every target has args and returns validators
  • [ ] 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 dev and saw the job listed under Schedules

Docs

  • https://docs.convex.dev/llms.txt
  • https://docs.convex.dev/scheduling/cron-jobs
  • https://docs.convex.dev/scheduling/scheduled-functions
  • https://docs.convex.dev/database/advanced/system-tables