The answer. A Worker becomes a scheduled job by exporting scheduled(controller, env, ctx) and listing cron expressions under triggers.crons. All expressions invoke the same handler; controller.cron holds the exact string that fired, so a switch on it routes work. Schedules are evaluated in UTC, support the usual five fields plus extensions like LW (last weekday), and take up to 15 minutes to propagate after a deploy. The runtime waits for the handler's promise (15-minute wall clock, 30 s of CPU for sub-hourly schedules), so a single await is enough; ctx.waitUntil is for running legs concurrently and for choosing which promise's failure marks the run as failed in the Past Events table. Locally, wrangler dev --test-scheduled exposes a route you curl to fire the handler on demand.
The pattern.
[triggers]
crons = ["*/15 * * * *", "0 8 * * *"] # UTC; the deploy replaces whatever crons were live
export default {
async scheduled(controller, env, ctx) {
switch (controller.cron) {
case "*/15 * * * *": await runQuarterHourLegs(env, ctx); break;
case "0 8 * * *": await sendDailyDigest(env); break;
}
},
} satisfies ExportedHandler<Env>;
npx wrangler dev --test-scheduled
curl "http://localhost:8787/cdn-cgi/local/scheduled?cron=*/15+*+*+*+*&format=json" # {"outcome":"ok"}
Watch out.
crons = []deletes every trigger on deploy; leaving thetriggerskey out keeps the deployed ones. Named environments do not inherit triggers at all.- Accounts get 5 Cron Triggers on Free and 250 on Paid, counted across Workers.
- A cron run has no request, so nothing short-circuits a hung leg except the 15-minute wall clock; isolate legs and add a heartbeat.
Related: cron-one-tick-many-isolated-legs · cron-heartbeat-dead-mans-switch · wrangler-environments-inherit-nothing-that-binds