6.2 KiB
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:
- Verify
scheduled()is exported:
export default {
async scheduled(controller, env, ctx) {
console.log("Cron triggered");
},
};
- Start dev server:
npx wrangler dev
- Use correct endpoint format (URL-encode spaces as
+):
# Correct
curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*"
# Wrong (will fail)
curl "http://localhost:8787/__scheduled?cron=*/5 * * * *"
- Update Wrangler if outdated:
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:
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:
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:
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:
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-workersfor realistic env
Integration tests:
- Test via
/__scheduledendpoint in dev environment - Verify idempotency logic with duplicate
scheduledTimevalues - Test error handling and retry behavior
Production: Start with long intervals (*/30 * * * *), monitor Cron Events for 24h, set up alerts before reducing interval