Files
claude-skills/cloudflare/references/cron-triggers/gotchas.md
2026-01-30 03:04:10 +00:00

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 = 170 17 * * *
  • 2am EST (UTC-5) → (2 - (-5) + 24) % 24 = 70 7 * * *
  • 6pm JST (UTC+9) → (18 - 9 + 24) % 24 = 33 % 24 = 90 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:
export default {
  async scheduled(controller, env, ctx) {
    console.log("Cron triggered");
  },
};
  1. Start dev server:
npx wrangler dev
  1. 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 * * * *"
  1. 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-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