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:

  • removeDeletedProjects is an internal mutation scheduled by the cron job. It scans projects, finds the ones with deletedAt set, and schedules internal.projects.deleteProjectCascade for each one.

  • The actual batched delete logic still lives in projects.deleteProjectCascade from 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 time
  • crons.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