# Cron Triggers Gotchas ## Common Errors ### "Timezone Issues" **Problem:** Cron runs at wrong time relative to local timezone **Cause:** All crons execute in UTC, no local timezone support **Solution:** Convert local time to UTC manually **Conversion formula:** `utcHour = (localHour - utcOffset + 24) % 24` **Examples:** - 9am PST (UTC-8) → `(9 - (-8) + 24) % 24 = 17` → `0 17 * * *` - 2am EST (UTC-5) → `(2 - (-5) + 24) % 24 = 7` → `0 7 * * *` - 6pm JST (UTC+9) → `(18 - 9 + 24) % 24 = 33 % 24 = 9` → `0 9 * * *` **Daylight Saving Time:** Adjust manually when DST changes, or schedule at times unaffected by DST (e.g., 2am-4am local time usually safe) ### "Cron Not Executing" **Cause:** Missing `scheduled()` export, invalid syntax, propagation delay (<15min), or plan limits **Solution:** Verify export exists, validate at crontab.guru, wait 15+ min after deploy, check plan limits ### "Duplicate Executions" **Cause:** At-least-once delivery **Solution:** Track execution IDs in KV - see idempotency pattern below ### "Execution Failures" **Cause:** CPU exceeded, unhandled exceptions, network timeouts, binding errors **Solution:** Use try-catch, AbortController timeouts, `ctx.waitUntil()` for long ops, or Workflows for heavy tasks ### "Local Testing Not Working" **Problem:** `/__scheduled` endpoint returns 404 or doesn't trigger handler **Cause:** Missing `scheduled()` export, wrangler not running, or incorrect endpoint format **Solution:** 1. Verify `scheduled()` is exported: ```typescript export default { async scheduled(controller, env, ctx) { console.log("Cron triggered"); }, }; ``` 2. Start dev server: ```bash npx wrangler dev ``` 3. Use correct endpoint format (URL-encode spaces as `+`): ```bash # Correct curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*" # Wrong (will fail) curl "http://localhost:8787/__scheduled?cron=*/5 * * * *" ``` 4. Update Wrangler if outdated: ```bash npm install -g wrangler@latest ``` ### "waitUntil() Tasks Not Completing" **Problem:** Background tasks in `ctx.waitUntil()` fail silently or don't execute **Cause:** Promises rejected without error handling, or handler returns before promise settles **Solution:** Always await or handle errors in waitUntil promises: ```typescript export default { async scheduled(controller, env, ctx) { // BAD: Silent failures ctx.waitUntil(riskyOperation()); // GOOD: Explicit error handling ctx.waitUntil( riskyOperation().catch(err => { console.error("Background task failed:", err); return logError(err, env); }) ); }, }; ``` ### "Idempotency Issues" **Problem:** At-least-once delivery causes duplicate side effects (double charges, duplicate emails) **Cause:** No deduplication mechanism **Solution:** Use KV to track execution IDs: ```typescript export default { async scheduled(controller, env, ctx) { const executionId = `${controller.cron}-${controller.scheduledTime}`; const existing = await env.EXECUTIONS.get(executionId); if (existing) { console.log("Already executed, skipping"); controller.noRetry(); return; } await env.EXECUTIONS.put(executionId, "1", { expirationTtl: 86400 }); // 24h TTL await performIdempotentOperation(env); }, }; ``` ### "Security Concerns" **Problem:** `__scheduled` endpoint exposed in production allows unauthorized cron triggering **Cause:** Testing endpoint available in deployed Workers **Solution:** Block `__scheduled` in production: ```typescript export default { async fetch(request, env, ctx) { const url = new URL(request.url); // Block __scheduled in production if (url.pathname === "/__scheduled" && env.ENVIRONMENT === "production") { return new Response("Not Found", { status: 404 }); } return handleRequest(request, env, ctx); }, async scheduled(controller, env, ctx) { // Your cron logic }, }; ``` **Also:** Use `env.API_KEY` for secrets (never hardcode) **Alternative:** Add middleware to verify request origin: ```typescript export default { async fetch(request, env, ctx) { const url = new URL(request.url); if (url.pathname === "/__scheduled") { // Check Cloudflare headers to verify internal request const cfRay = request.headers.get("cf-ray"); if (!cfRay && env.ENVIRONMENT === "production") { return new Response("Not Found", { status: 404 }); } } return handleRequest(request, env, ctx); }, async scheduled(controller, env, ctx) { // Your cron logic }, }; ``` ## Limits & Quotas | Limit | Free | Paid | Notes | |-------|------|------|-------| | Triggers per Worker | 3 | Unlimited | Maximum cron schedules per Worker | | CPU time | 10ms | 50ms | May need `ctx.waitUntil()` or Workflows | | Execution guarantee | At-least-once | At-least-once | Duplicates possible - use idempotency | | Propagation delay | Up to 15 minutes | Up to 15 minutes | Time for changes to take effect globally | | Min interval | 1 minute | 1 minute | Cannot schedule more frequently | | Cron accuracy | ±1 minute | ±1 minute | Execution may drift slightly | ## Testing Best Practices **Unit tests:** - Mock `ScheduledController`, `ExecutionContext`, and bindings - Test each cron expression separately - Verify `noRetry()` is called when expected - Use Vitest with `@cloudflare/vitest-pool-workers` for realistic env **Integration tests:** - Test via `/__scheduled` endpoint in dev environment - Verify idempotency logic with duplicate `scheduledTime` values - Test error handling and retry behavior **Production:** Start with long intervals (`*/30 * * * *`), monitor Cron Events for 24h, set up alerts before reducing interval ## Resources - [Cron Triggers Docs](https://developers.cloudflare.com/workers/configuration/cron-triggers/) - [Scheduled Handler API](https://developers.cloudflare.com/workers/runtime-apis/handlers/scheduled/) - [Cloudflare Workflows](https://developers.cloudflare.com/workflows/) - [Workers Limits](https://developers.cloudflare.com/workers/platform/limits/) - [Crontab Guru](https://crontab.guru/) - Validator - [Vitest Pool Workers](https://github.com/cloudflare/workers-sdk/tree/main/fixtures/vitest-pool-workers-examples)