- docs/using-d1.md and docs/using-cron.md for module authors - architecture, codebase-summary, adding-a-module, code-standards, deployment-guide refreshed - CLAUDE.md module contract shows optional crons[] and sql in init - docs/todo.md tracks manual follow-ups (D1 UUID, first deploy, smoke tests)
7.3 KiB
Using Cron (Scheduled Jobs)
Cron allows modules to run scheduled tasks at fixed intervals. Use crons for cleanup (purge old data), maintenance (recompute stats), or periodic notifications.
Declaring Crons
In your module's default export, add a crons array:
export default {
name: "mymod",
init: async ({ db, sql, env }) => { /* ... */ },
commands: [ /* ... */ ],
crons: [
{
schedule: "0 17 * * *", // 5 PM UTC daily
name: "cleanup", // human-readable identifier
handler: async (event, ctx) => {
// event.cron = "0 17 * * *"
// event.scheduledTime = timestamp (ms)
// ctx = { db, sql, env } (same as module init)
},
},
],
};
Handler signature:
async (event, { db, sql, env }) => {
// event.cron — the schedule string that fired
// event.scheduledTime — Unix timestamp (ms)
// db — namespaced KV store (same as init)
// sql — namespaced D1 store (same as init), null if not bound
// env — raw worker environment
}
Cron Expression Syntax
Standard 5-field cron format (minute, hour, day-of-month, month, day-of-week):
minute hour day-of-month month day-of-week
0-59 0-23 1-31 1-12 0-6 (0=Sunday)
"0 17 * * *" — 5 PM UTC daily
"*/5 * * * *" — every 5 minutes
"0 0 1 * *" — midnight on the 1st of each month
"0 9 * * 1" — 9 AM UTC every Monday
"30 2 * * *" — 2:30 AM UTC daily
See Cloudflare's cron expression docs for full syntax.
Registering in wrangler.toml
Important: Crons declared in the module MUST also be listed in wrangler.toml. This is because Cloudflare needs to know what schedules to fire at deploy time.
Edit wrangler.toml:
[triggers]
crons = ["0 17 * * *", "0 0 * * *", "*/5 * * * *"]
Both the module contract and the [triggers] crons array must list the same schedules. If a schedule is in the module but not in wrangler.toml, Cloudflare won't fire it. If it's in wrangler.toml but not in any module, the worker won't know what to do with it.
Multiple modules can share a schedule — all matching handlers will fire (fan-out). Each module must declare its own crons entry; the registry validates them at load time.
Handler Details
Error Isolation
If one handler fails, other handlers still run. Each handler is wrapped in try/catch:
// In cron-dispatcher.js
for (const entry of matching) {
ctx.waitUntil(
(async () => {
try {
await entry.handler(event, handlerCtx);
} catch (err) {
console.error(`[cron] handler failed:`, err);
}
})(),
);
}
Errors are logged to the Workers console but don't crash the dispatch loop.
Execution Time Limits
Cloudflare cron tasks have a 15-minute wall-clock limit. Operations exceeding this timeout are killed. For large data operations:
- Batch in chunks (e.g., delete 1000 rows at a time, looping)
- Use pagination to avoid loading entire datasets into memory
- Monitor execution time and add logging
Context Availability
Cron handlers run in the same Worker runtime as HTTP handlers, so they have access to:
db— the module's namespaced KV store (read/write)sql— the module's namespaced D1 store (read/write), or null if not configuredenv— all Worker environment bindings (secrets, etc.)
Return Value
Handlers should return Promise<void>. The runtime ignores return values.
Local Testing
Use the local wrangler dev server to simulate cron triggers:
npm run dev
In another terminal, send a simulated cron request:
# Trigger the 5 PM daily cron
curl "http://localhost:8787/__scheduled?cron=0+17+*+*+*"
# URL-encode the cron string (spaces → +)
The Worker responds with 200 and logs handler output to the dev server console.
Simulating Multiple Crons
If you have several crons with different schedules, test each by passing the exact schedule string:
curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*" # every 5 min
curl "http://localhost:8787/__scheduled?cron=0+0+1+*+*" # monthly
Worked Example: Trade Retention
The trading module uses a daily cron at 0 17 * * * (5 PM UTC) to trim old trades:
Module declaration (src/modules/trading/index.js):
crons: [
{
schedule: "0 17 * * *",
name: "trim-trades",
handler: (event, ctx) => trimTradesHandler(event, ctx),
},
],
wrangler.toml:
[triggers]
crons = ["0 17 * * *"]
Handler (src/modules/trading/retention.js):
/**
* Delete trades older than 90 days.
*/
export async function trimTradesHandler(event, { sql }) {
if (!sql) return; // database not configured
const ninetyDaysAgoMs = Date.now() - 90 * 24 * 60 * 60 * 1000;
const result = await sql.run(
"DELETE FROM trading_trades WHERE ts < ?",
ninetyDaysAgoMs
);
console.log(`[cron] trim-trades: deleted ${result.changes} old trades`);
}
wrangler.toml:
[triggers]
crons = ["0 17 * * *"]
At 5 PM UTC every day, Cloudflare fires the 0 17 * * * cron. The Worker loads the registry, finds the trading module's handler, executes trimTradesHandler, and logs the number of deleted rows.
Worked Example: Stats Recalculation
Imagine a leaderboard module that caches top-10 stats:
export default {
name: "leaderboard",
init: async ({ db, sql }) => {
// ...
},
crons: [
{
schedule: "0 12 * * *", // noon UTC daily
name: "refresh-stats",
handler: async (event, { sql, db }) => {
if (!sql) return;
// Recompute aggregate stats from raw data
const topTen = await sql.all(
`SELECT user_id, SUM(score) as total_score
FROM leaderboard_plays
GROUP BY user_id
ORDER BY total_score DESC
LIMIT 10`
);
// Cache in KV for fast /leaderboard command response
await db.putJSON("cached_top_10", topTen);
console.log(`[cron] refresh-stats: updated top 10`);
},
},
],
};
Every day at noon, the leaderboard updates its cached stats without waiting for a user request.
Crons and Cold Starts
Crons execute on a fresh Worker instance (potential cold start). Module init hooks run before the first handler, so cron handlers can safely assume initialization is complete.
If init throws, the cron fires anyway but has sql and db in a half-initialized state. Handle this gracefully:
handler: async (event, { sql, db }) => {
if (!sql) {
console.warn("sql store not available, skipping");
return;
}
// proceed with confidence
}
Adding a New Cron
-
Declare in module:
crons: [ { schedule: "0 3 * * *", name: "my-cron", handler: myHandler } ], -
Add to wrangler.toml:
[triggers] crons = ["0 3 * * *", "0 17 * * *"] # keep existing schedules -
Deploy:
npm run deploy -
Test locally:
npm run dev # in another terminal: curl "http://localhost:8787/__scheduled?cron=0+3+*+*+*"
Monitoring Crons
Cron execution is logged to the Cloudflare Workers console. Check the tail:
npx wrangler tail
Look for [cron] prefixed log lines to see which crons ran and what they did.