State and background work

Run scheduled maintenance

Add a scheduled handler for bounded cleanup work and test it without relying on an incoming HTTP request.

Exports shouldn’t live forever. Link Vault deletes export metadata older than 30 days, and that’s a job for a Cron Trigger: Cloudflare calls your Worker on a schedule, no incoming request needed.

Add the schedule to wrangler.jsonc. Times are in UTC, always:

{
  "triggers": { "crons": ["0 3 * * *"] }
}

That’s every day at 03:00 UTC.

The scheduled handler

Cron Triggers call a scheduled method, which sits next to fetch and queue on the exported object:

export default {
  fetch: app.fetch,
  async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
    ctx.waitUntil(deleteExpiredExports(env))
  }
}

event.scheduledTime gives you the time the run was meant to happen, and event.cron tells you which schedule fired if you have several.

A cron is a trigger, not a clock

The run can start late. Two runs can overlap if the previous one is slow. So cleanup must be safe to repeat and safe to run twice at once.

Two habits make that true. First, compute the cutoff once from event.scheduledTime and pass it into the delete, so a delayed run still deletes the same rows it was supposed to. Second, delete a bounded page of rows, not the whole table:

DELETE FROM exports
WHERE id IN (
  SELECT id FROM exports WHERE created_at < ? LIMIT 500
)

If 500 rows went, there may be more. Store a cursor, or enqueue the next page, and let the next run continue. A single invocation that tries to scan a million rows hits the CPU limit and deletes nothing.

Log a safe summary: the scheduled time, the cutoff, how many rows went, and the error class if something failed. Not the row contents.

Test it without waiting for 03:00

Waiting for the real cron is not a development loop. Wrangler exposes a local endpoint that fires the handler on demand:

npx wrangler dev --test-scheduled
curl "http://localhost:8787/cdn-cgi/local/scheduled?cron=0+3+*+*+*"

The response says Ran scheduled event. Your dev server log shows the summary line you wrote.

For a proper test, call the handler directly from Vitest with a fixed scheduledTime and two fixtures: one export from 40 days ago, one from yesterday. After the run, the old one is gone and the new one is still there. That’s the assertion.

A common failure: the cron runs in production, but the deleted count is always 0. Usually the created_at format in the rows doesn’t match the format of the cutoff string, so the comparison never matches. Store ISO 8601 timestamps everywhere and the problem goes away.

Now add the scheduled handler to your project, write that two-fixture test, and run it against the local D1 database. Then hit the --test-scheduled endpoint once and check the log line.

Lesson completed