Schedule Cleanup with a Cron Job
The soft-deleted projects and their tasks still exist in the database. We need a background process to permanently remove them. Convex provides cron jobs for this: functions that run on a schedule.
Create convex/crons.ts:
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
crons.daily(
"clean up deleted projects",
{
hourUTC: 3,
minuteUTC: 0,
},
internal.cleanup.removeDeletedProjects,
);
export default crons;
This schedules cleanup.removeDeletedProjects to run daily at 3:00 AM UTC. The cron job calls an internal function. It runs on the server, and no client triggers it.
Now create convex/cleanup.ts with the internal mutation that schedules the actual cleanup:
import { internalMutation } from "./_generated/server";
import { internal } from "./_generated/api";
export const removeDeletedProjects = internalMutation({
args: {},
handler: async (ctx) => {
const projects = await ctx.db
.query("projects")
.withIndex("by_deletedAt", (q) => q.gt("deletedAt", 0))
.collect();
for (const project of projects) {
await ctx.scheduler.runAfter(0, internal.projects.deleteProjectCascade, {
id: project._id,
});
}
},
});
Let’s walk through this:
-
removeDeletedProjectsis an internal mutation scheduled by the cron job. It scans projects, finds the ones withdeletedAtset, and schedulesinternal.projects.deleteProjectCascadefor each one. -
The actual batched delete logic still lives in
projects.deleteProjectCascadefrom the previous section. Reusing that internal mutation keeps the delete behavior in one place.
The Full Flow
Here is the full flow:
User clicks "Delete Project"
↓
Mutation sets deletedAt = Date.now() (instant)
↓
Project disappears from list query (instant)
↓
User sees project is gone (instant)
... later, at 3:00 AM UTC ...
Cron job triggers removeDeletedProjects
↓
Mutation finds soft-deleted projects
↓
Schedules deleteProjectCascade for each one
↓
Internal mutation deletes tasks in batches, then deletes the project
The user gets instant feedback. The heavy cleanup work happens later, in the background, so the user never waits for it.
Cron Job Details
Convex supports two types of scheduled runs:
crons.daily(...): runs once a day at a specific timecrons.interval(...): runs at a fixed interval (e.g., every hour)
// Run every hour
crons.interval(
"hourly cleanup",
{
hours: 1,
},
internal.cleanup.removeDeletedProjects,
);
// Run daily at 3:00 AM UTC
crons.daily(
"daily cleanup",
{
hourUTC: 3,
minuteUTC: 0,
},
internal.cleanup.removeDeletedProjects,
);
You can see your scheduled cron jobs in the Convex dashboard. Go to “Schedules” in the left sidebar and look under the “Cron Jobs” section. The dashboard also shows the last run time and whether it succeeded or failed.
Checkpoint: Commit your progress.
git add .
git commit -m "planner-12: Add cron job cleanup"
git push