The Cascade Delete Problem

Let’s revisit how we implemented the delete project mutation in convex/projects.ts:

export const remove = mutation({
  args: { id: v.id("projects") },
  handler: async (ctx, args) => {
    // Delete all tasks belonging to this project
    const tasks = await ctx.db
      .query("tasks")
      .withIndex("by_project", (q) => q.eq("projectId", args.id))
      .collect();

    for (const task of tasks) {
      await ctx.db.delete(task._id);
    }

    // Delete the project itself
    await ctx.db.delete(args.id);
  },
});

This works for a project with hundreds of tasks, which is probably fine for our demo. But suppose we deployed this app for real, and a user has a project that runs for a few years and accumulates thousands of tasks. When they delete that project, the mutation would try to delete all those tasks in a single transaction, and that is likely to fail.

Convex enforces limits on mutations to keep the system fast and reliable. A mutation can read and write a limited number of documents and must finish within a time budget.

Convex’s recommended pattern for this kind of database-only workflow is:

  1. Let the client call a mutation to express the user’s intent
  2. From that mutation, use ctx.scheduler.runAfter() to schedule an internal mutation
  3. Have the internal mutation delete one batch of tasks and, if needed, schedule itself again

Why this pattern?

  • The client still calls a mutation, which is the normal way to express a user-triggered write
  • Scheduling from a mutation is part of the transaction, so the background work is only queued if the mutation commits
  • Scheduled mutations are durable and retried by Convex
  • Each batch runs in its own transaction, so the cascade delete can scale beyond a single mutation’s limits

Internal Functions

Before we write the delete worker, we need to think about security. The mutation that deletes tasks in batches should not be callable from the client. It is a server-side implementation detail. Convex provides internal functions for this purpose.

An internal function can only be called from other server functions (queries, mutations, etc.). It cannot be called from the client. This matters for security. You do not want clients calling the internal mutation directly, changing the batch size, or deleting tasks that belong to other projects.

Implementation

Let’s update convex/projects.ts. First, update this import at the top:

- import { query, mutation } from "./_generated/server";
+ import { query, mutation, internalMutation } from "./_generated/server";

The internalMutation is like mutation, but it can only be called from the server. It is not exposed to clients.

Next, add this import to access internal functions:

import { internal } from "./_generated/api";

The internal is like api, but for calling internal functions. While api.projects.remove calls the public mutation, internal.projects.deleteProjectCascade refers to the internal one.

Next, add a constant for the batch size to the top of the file:

const CASCADE_DELETE_BATCH_SIZE = 100;

Next, define the internal mutation at the bottom of the file:

export const deleteProjectCascade = internalMutation({
  args: {
    id: v.id("projects"),
  },
  handler: async (ctx, args) => {
    const tasks = await ctx.db
      .query("tasks")
      .withIndex("by_project", (q) => q.eq("projectId", args.id))
      .take(CASCADE_DELETE_BATCH_SIZE);

    for (const task of tasks) {
      await ctx.db.delete(task._id);
    }

    if (tasks.length === CASCADE_DELETE_BATCH_SIZE) {
      await ctx.scheduler.runAfter(0, internal.projects.deleteProjectCascade, {
        id: args.id,
      });
      return;
    }

    await ctx.db.delete(args.id);
  },
});

The deleteProjectCascade internal mutation deletes one batch of tasks, then schedules itself again if there are more:

  1. It loads up to CASCADE_DELETE_BATCH_SIZE tasks for the project
  2. It deletes those tasks in the current transaction
  3. If the batch was full, it schedules itself again
  4. If fewer than CASCADE_DELETE_BATCH_SIZE tasks remain, it knows the cascade is finished and deletes the project itself

Finally, update the public remove mutation to schedule the cascade delete:

export const remove = mutation({
  args: {
    id: v.id("projects"),
  },
  handler: async (ctx, args) => {
    const project = await ctx.db.get(args.id);
    if (!project) {
      return;
    }

    await ctx.scheduler.runAfter(0, internal.projects.deleteProjectCascade, {
      id: args.id,
    });
  },
});

This mutation does not perform the deletion itself. It just records the user’s intent and schedules the background delete to run immediately after the mutation commits.

On the client, we continue to call api.projects.remove when the user clicks the delete button. The cascade delete happens in the background, and the client does not have to worry about the details.

Checkpoint: Commit your progress.

git add .
git commit -m "planner-10: Add scheduled cascade delete"
git push