| name | cloudflare-cron-triggers |
| description | Complete knowledge domain for Cloudflare Cron Triggers - scheduled execution of Workers using
cron expressions for periodic tasks, maintenance jobs, and automated workflows.
Use when: scheduling Workers to run periodically, adding cron triggers to Workers, configuring
scheduled tasks, testing cron handlers, combining crons with Workflows, enabling Green Compute,
handling multiple schedules, or encountering "scheduled handler not found", "cron expression invalid",
"changes not propagating", "handler does not export", "timezone issues" errors.
Keywords: cloudflare cron, cron triggers, scheduled workers, scheduled handler, periodic tasks,
background jobs, scheduled tasks, cron expression, wrangler crons, scheduled event, green compute,
workflow triggers, maintenance tasks, scheduled() handler, ScheduledController, UTC timezone
|
| license | MIT |
Cloudflare Cron Triggers
Status: Production Ready ✅
Last Updated: 2025-10-23
Dependencies: cloudflare-worker-base (for Worker setup)
Latest Versions: wrangler@4.43.0, @cloudflare/workers-types@4.20251014.0
Quick Start (5 Minutes)
1. Add Scheduled Handler to Your Worker
src/index.ts:
export default {
async scheduled(
controller: ScheduledController,
env: Env,
ctx: ExecutionContext
): Promise<void> {
console.log('Cron job executed at:', new Date(controller.scheduledTime));
console.log('Triggered by cron:', controller.cron);
await doPeriodicTask(env);
},
};
Why this matters:
- Handler must be named exactly
scheduled (not scheduledHandler or onScheduled)
- Must be exported in default export object
- Must use ES modules format (not Service Worker format)
2. Configure Cron Trigger in Wrangler
wrangler.jsonc:
{
"name": "my-scheduled-worker",
"main": "src/index.ts",
"compatibility_date": "2025-10-23",
"triggers": {
"crons": [
"0 * * * *"
]
}
}
CRITICAL:
- Cron expressions use 5 fields:
minute hour day-of-month month day-of-week
- All times are UTC only (no timezone conversion)
- Changes take up to 15 minutes to propagate globally
3. Test Locally
npx wrangler dev --test-scheduled
curl "http://localhost:8787/__scheduled?cron=0+*+*+*+*"
Testing tips:
/__scheduled endpoint is only available with --test-scheduled flag
- Can pass any cron expression in query parameter
- Python Workers use
/cdn-cgi/handler/scheduled instead
4. Deploy
npm run deploy
npx wrangler deploy
After deployment:
- Changes may take up to 15 minutes to propagate
- Check dashboard: Workers & Pages > [Your Worker] > Cron Triggers
- View past executions in Logs tab
Cron Expression Syntax
Five-Field Format
* * * * *
│ │ │ │ │
│ │ │ │ └─── Day of Week (0-6, Sunday=0)
│ │ │ └───── Month (1-12)
│ │ └─────── Day of Month (1-31)
│ └───────── Hour (0-23)
└─────────── Minute (0-59)
Special Characters
| Character | Meaning | Example |
|---|
* | Every | * * * * * = every minute |
, | List | 0,30 * * * * = every hour at :00 and :30 |
- | Range | 0 9-17 * * * = every hour from 9am-5pm |
/ | Step | */15 * * * * = every 15 minutes |
Common Patterns
* * * * *
*/5 * * * *
*/15 * * * *
0 * * * *
30 * * * *
0 */6 * * *
0 0 * * *
0 12 * * *
30 3 * * *
0 9 * * 1
0 9 * * 1-5
0 0 * * 0
0 0 1 * *
0 6,18 * * *
*/30 9-17 * * 1-5
CRITICAL: UTC Timezone Only
- All cron triggers execute on UTC time
- No timezone conversion available
- Convert your local time to UTC manually
- Example: 9am PST = 5pm UTC (next day during DST)
ScheduledController Interface
interface ScheduledController {
readonly cron: string;
readonly type: string;
readonly scheduledTime: number;
}
Properties
controller.cron (string)
The cron expression that triggered this execution.
export default {
async scheduled(controller: ScheduledController, env: Env): Promise<void> {
console.log(`Triggered by: ${controller.cron}`);
},
};
Use case: Differentiate between multiple cron schedules (see Multiple Cron Triggers pattern).
controller.type (string)
Always returns "scheduled" for cron-triggered executions.
if (controller.type === 'scheduled') {
}
controller.scheduledTime (number)
Unix timestamp (milliseconds since epoch) when this execution was scheduled to run.
export default {
async scheduled(controller: ScheduledController): Promise<void> {
const scheduledDate = new Date(controller.scheduledTime);
console.log(`Scheduled for: ${scheduledDate.toISOString()}`);
},
};
Note: This is the scheduled time, not the actual execution time. Due to system load, actual execution may be slightly delayed (usually <1 second).
Execution Context
export default {
async scheduled(
controller: ScheduledController,
env: Env,
ctx: ExecutionContext
): Promise<void> {
ctx.waitUntil(logToAnalytics(env));
},
};
ctx.waitUntil(promise: Promise<any>)
Extends the execution context to wait for async operations to complete after the handler returns.
Use cases:
- Logging to external services
- Analytics tracking
- Cleanup operations
- Non-critical background tasks
export default {
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
await processData(env);
ctx.waitUntil(sendMetrics(env));
ctx.waitUntil(cleanupOldData(env));
ctx.waitUntil(notifySlack({ message: 'Cron completed' }));
},
};
Important: First waitUntil() that fails will be reported as the status in dashboard logs.
Integration Patterns
1. Standalone Scheduled Worker
Best for: Workers that only run on schedule (no HTTP requests)
interface Env {
DB: D1Database;
MY_BUCKET: R2Bucket;
}
export default {
async scheduled(
controller: ScheduledController,
env: Env,
ctx: ExecutionContext
): Promise<void> {
console.log('Running scheduled maintenance...');
await env.DB.prepare('DELETE FROM sessions WHERE expires_at < ?')
.bind(Date.now())
.run();
const report = await generateDailyReport(env.DB);
await env.MY_BUCKET.put(
`reports/${new Date().toISOString().split('T')[0]}.json`,
JSON.stringify(report)
);
console.log('Maintenance complete');
},
};
2. Combined with Hono (Fetch + Scheduled)
Best for: Workers that handle both HTTP requests and scheduled tasks
import { Hono } from 'hono';
interface Env {
DB: D1Database;
}
const app = new Hono<{ Bindings: Env }>();
app.get('/', (c) => c.text('Worker is running'));
app.get('/api/stats', async (c) => {
const stats = await c.env.DB.prepare('SELECT COUNT(*) as count FROM users').first();
return c.json(stats);
});
export default {
fetch: app.fetch,
async scheduled(
controller: ScheduledController,
env: Env,
ctx: ExecutionContext
): Promise<void> {
console.log('Cron triggered:', controller.cron);
await updateCache(env.DB);
ctx.waitUntil(logExecution(controller.scheduledTime));
},
};
Why this pattern:
- One Worker handles both use cases
- Share environment bindings
- Reduce number of Workers to manage
- Lower costs (one Worker subscription)
3. Multiple Cron Triggers
Best for: Different schedules for different tasks
wrangler.jsonc:
{
"triggers": {
"crons": [
"*/5 * * * *",
"0 */6 * * *",
"0 0 * * *"
]
}
}
src/index.ts:
export default {
async scheduled(
controller: ScheduledController,
env: Env,
ctx: ExecutionContext
): Promise<void> {
switch (controller.cron) {
case '*/5 * * * *':
await checkSystemHealth(env);
break;
case '0 */6 * * *':
await syncExternalData(env);
break;
case '0 0 * * *':
await generateDailyReports(env);
await cleanupOldData(env);
break;
default:
console.warn(`Unknown cron trigger: ${controller.cron}`);
}
},
};
CRITICAL:
- Use exact cron expression match (whitespace sensitive)
- Maximum 3 cron triggers per Worker (Free plan)
- Standard/Paid plan supports more (check limits)
4. Accessing Environment Bindings
All Worker bindings available in scheduled handler:
interface Env {
DB: D1Database;
MY_BUCKET: R2Bucket;
KV_NAMESPACE: KVNamespace;
AI: Ai;
VECTOR_INDEX: VectorizeIndex;
MY_QUEUE: Queue;
MY_WORKFLOW: Workflow;
RATE_LIMITER: DurableObjectNamespace;
API_KEY: string;
}
export default {
async scheduled(controller: ScheduledController, env: Env): Promise<void> {
const users = await env.DB.prepare('SELECT * FROM users WHERE active = 1').all();
const file = await env.MY_BUCKET.get('data.json');
const config = await env.KV_NAMESPACE.get('config', 'json');
const response = await env.AI.run('@cf/meta/llama-3-8b-instruct', {
prompt: 'Summarize today\'s data',
});
await env.MY_QUEUE.send({ type: 'process', data: users.results });
await env.MY_WORKFLOW.create({ input: { timestamp: Date.now() } });
await fetch('https://api.example.com/webhook', {
headers: { Authorization: `Bearer ${env.API_KEY}` },
});
},
};
5. Combining with Workflows
Best for: Multi-step, long-running tasks triggered on schedule
wrangler.jsonc:
{
"triggers": {
"crons": ["0 2 * * *"]
},
"workflows": [
{
"name": "daily-report-workflow",
"binding": "DAILY_REPORT"
}
]
}
src/index.ts:
interface Env {
DAILY_REPORT: Workflow;
}
export default {
async scheduled(controller: ScheduledController, env: Env): Promise<void> {
console.log('Triggering daily report workflow...');
const instance = await env.DAILY_REPORT.create({
params: {
date: new Date().toISOString().split('T')[0],
reportType: 'daily-summary',
},
});
console.log(`Workflow started: ${instance.id}`);
},
};
Why use Workflows:
- Workflows can run for hours (cron handlers have CPU limits)
- Built-in retry and error handling
- State persistence across steps
- Better for complex, multi-step processes
Reference: Cloudflare Workflows Docs
6. Error Handling in Scheduled Handlers
export default {
async scheduled(
controller: ScheduledController,
env: Env,
ctx: ExecutionContext
): Promise<void> {
try {
await performScheduledTask(env);
} catch (error) {
console.error('Scheduled task failed:', error);
await sendAlert({
worker: 'my-scheduled-worker',
cron: controller.cron,
error: error.message,
timestamp: new Date(controller.scheduledTime).toISOString(),
});
ctx.waitUntil(
env.DB.prepare(
'INSERT INTO cron_failures (cron, error, timestamp) VALUES (?, ?, ?)'
)
.bind(controller.cron, error.message, Date.now())
.run()
);
throw error;
}
},
};
async function sendAlert(details: any): Promise<void> {
await fetch('https://hooks.slack.com/services/YOUR/WEBHOOK/URL', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `🚨 Cron job failed: ${details.worker}`,
blocks: [
{
type: 'section',
fields: [
{ type: 'mrkdwn', text: `*Worker:*\n${details.worker}` },
{ type: 'mrkdwn', text: `*Cron:*\n${details.cron}` },
{ type: 'mrkdwn', text: `*Error:*\n${details.error}` },
{ type: 'mrkdwn', text: `*Time:*\n${details.timestamp}` },
],
},
],
}),
});
}
Wrangler Configuration
Basic Configuration
{
"name": "my-scheduled-worker",
"main": "src/index.ts",
"compatibility_date": "2025-10-23",
"triggers": {
"crons": ["0 * * * *"]
}
}
Multiple Cron Triggers
{
"triggers": {
"crons": [
"*/5 * * * *",
"0 */6 * * *",
"0 2 * * *",
"0 0 * * 1"
]
}
}
Limits:
- Free: 3 cron schedules max
- Paid: Higher limits (check current limits)
Environment-Specific Crons
{
"name": "my-worker",
"main": "src/index.ts",
"env": {
"dev": {
"triggers": {
"crons": ["*/5 * * * *"]
}
},
"staging": {
"triggers": {
"crons": ["*/30 * * * *"]
}
},
"production": {
"triggers": {
"crons": ["0 * * * *"]
}
}
}
}
Deploy specific environment:
npx wrangler deploy --env dev
npx wrangler deploy --env production
Removing All Cron Triggers
{
"triggers": {
"crons": []
}
}
After deploy, Worker will no longer execute on schedule.
Testing & Development
Local Testing with Wrangler
npx wrangler dev --test-scheduled
This exposes /__scheduled endpoint for triggering scheduled handlers.
Trigger Scheduled Handler
curl "http://localhost:8787/__scheduled"
curl "http://localhost:8787/__scheduled?cron=0+*+*+*+*"
curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*"
Note: Use + instead of spaces in URL, or URL-encode properly.
Verify Handler Output
npx wrangler dev --test-scheduled
curl "http://localhost:8787/__scheduled?cron=0+*+*+*+*"
Output appears in wrangler dev terminal:
[wrangler:inf] GET /__scheduled?cron=0+*+*+*+* 200 OK (45ms)
Cron job executed at: 2025-10-23T15:00:00.000Z
Triggered by cron: 0 * * * *
Scheduled task completed successfully
Test Multiple Cron Expressions
curl "http://localhost:8787/__scheduled?cron=0+*+*+*+*"
curl "http://localhost:8787/__scheduled?cron=0+0+*+*+*"
curl "http://localhost:8787/__scheduled?cron=0+0+*+*+1"
Python Workers Testing
curl "http://localhost:8787/cdn-cgi/handler/scheduled?cron=*+*+*+*+*"
Green Compute
Run cron triggers only in data centers powered by renewable energy.
Enable Green Compute
Via Dashboard:
- Go to Workers & Pages
- In Account details section, find Compute Setting
- Click Change
- Select Green Compute
- Click Confirm
Applies to:
- All cron triggers in your account
- Reduces carbon footprint
- No additional cost
- May introduce slight delays in some regions
How it works:
- Cloudflare routes cron executions to green-powered data centers
- Uses renewable energy: wind, solar, hydroelectric
- Verified through Power Purchase Agreements (PPAs) and Renewable Energy Credits (RECs)
Known Issues Prevention
This skill prevents 6 documented issues:
Issue #1: Cron Changes Not Propagating
Error: Cron triggers updated in wrangler.jsonc but not executing
Source: Cloudflare Docs - Cron Triggers
Why It Happens:
- Changes to cron triggers take up to 15 minutes to propagate globally
- Cloudflare network needs time to update edge nodes
- No instant propagation like regular deploys
Prevention:
- Wait 15 minutes after deploy before expecting execution
- Check dashboard: Workers & Pages > [Worker] > Cron Triggers
- Use
wrangler triggers deploy for trigger-only changes
npx wrangler triggers deploy
Issue #2: Handler Does Not Export
Error: Handler does not export a 'scheduled' method
Source: Common deployment error
Why It Happens:
- Handler not named exactly
scheduled
- Handler not exported in default export object
- Using Service Worker format instead of ES modules
Prevention:
export default {
async scheduledHandler(controller, env, ctx) { }
};
export async function scheduled(controller, env, ctx) { }
export default {
async scheduled(controller, env, ctx) { }
};
Issue #3: UTC Timezone Confusion
Error: Cron runs at wrong time
Source: User expectation vs. reality
Why It Happens:
- All cron triggers run on UTC time only
- No timezone conversion available
- Users expect local timezone
Prevention:
Convert your local time to UTC manually:
{
"triggers": {
"crons": ["0 17 * * *"]
}
}
{
"triggers": {
"crons": ["0 23 * * *"]
}
}
Tools:
Issue #4: Invalid Cron Expression
Error: Cron doesn't execute, no error shown
Source: Silent validation failure
Why It Happens:
- Invalid cron syntax silently fails
- Validation happens at deploy, but may not be obvious
- Common mistakes: wrong field order, invalid ranges
Prevention:
"crons": ["0 0 * * * *"]
"crons": ["65 * * * *"]
"crons": ["0 0 * * 7"]
"crons": ["0 0 * * 0"]
Validation:
- Use Crontab Guru to validate expressions
- Check wrangler deploy output for errors
- Test locally with
--test-scheduled
Issue #5: Missing ES Modules Format
Error: Worker must use ES modules format
Source: Legacy Service Worker format
Why It Happens:
- Scheduled handler requires ES modules format
- Old Service Worker format not supported
- Mixed format in codebase
Prevention:
addEventListener('scheduled', (event) => {
event.waitUntil(handleScheduled(event));
});
export default {
async scheduled(controller, env, ctx) {
await handleScheduled(controller, env, ctx);
},
};
Issue #6: CPU Time Limits Exceeded
Error: CPU time limit exceeded
Source: Long-running scheduled tasks
Why It Happens:
- Default CPU limit: 30 seconds
- Long-running tasks exceed limit
- No automatic timeout extension
Prevention:
Option 1: Increase CPU limit in wrangler.jsonc
{
"limits": {
"cpu_ms": 300000
}
}
Option 2: Use Workflows for long-running tasks
export default {
async scheduled(controller, env, ctx) {
await env.MY_WORKFLOW.create({
params: { task: 'long-running-job' },
});
},
};
Option 3: Break into smaller chunks
export default {
async scheduled(controller, env, ctx) {
const batch = await getNextBatch(env.DB);
for (const item of batch) {
await processItem(item);
}
const hasMore = await hasMoreWork(env.DB);
if (hasMore) {
await env.MY_QUEUE.send({ type: 'continue-processing' });
}
},
};
Always Do ✅
- Use exact handler name - Must be
scheduled, not scheduledHandler or variants
- Use ES modules format - Export in default object, not addEventListener
- Convert to UTC - All cron times are UTC, convert from local timezone
- Wait 15 minutes - Cron changes take up to 15 min to propagate
- Test locally first - Use
wrangler dev --test-scheduled
- Validate cron syntax - Use Crontab Guru
- Handle errors gracefully - Log, alert, and optionally re-throw
- Use ctx.waitUntil() - For non-critical async operations
- Consider Workflows - For tasks that need >30 seconds CPU time
- Monitor executions - Check dashboard logs regularly
Never Do ❌
- Never assume local timezone - All crons run on UTC
- Never use 6-field cron expressions - Cloudflare uses 5-field format (no seconds)
- Never rely on instant propagation - Changes take up to 15 minutes
- Never use Service Worker format - Must use ES modules format
- Never forget error handling - Uncaught errors fail silently
- Never run CPU-intensive tasks without limit increase - Default 30s limit
- Never use day-of-week 7 - Use 0 for Sunday (0-6 range only)
- Never deploy without testing - Always test with
--test-scheduled first
- Never ignore execution logs - Dashboard shows past failures
- Never hardcode schedules for testing - Use environment-specific configs
Common Use Cases
1. Database Cleanup
Every day at 2am UTC: Delete old records
export default {
async scheduled(controller: ScheduledController, env: Env): Promise<void> {
const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000);
await env.DB.prepare('DELETE FROM sessions WHERE created_at < ?')
.bind(thirtyDaysAgo)
.run();
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
await env.DB.prepare('DELETE FROM users WHERE deleted_at < ?')
.bind(ninetyDaysAgo)
.run();
console.log('Database cleanup completed');
},
};
wrangler.jsonc:
{
"triggers": {
"crons": ["0 2 * * *"]
}
}
2. API Data Collection
Every 15 minutes: Fetch data from external API
interface Env {
DB: D1Database;
API_KEY: string;
}
export default {
async scheduled(controller: ScheduledController, env: Env): Promise<void> {
try {
const response = await fetch('https://api.example.com/v1/data', {
headers: {
Authorization: `Bearer ${env.API_KEY}`,
},
});
const data = await response.json();
for (const item of data.items) {
await env.DB.prepare(
'INSERT INTO collected_data (id, value, timestamp) VALUES (?, ?, ?)'
)
.bind(item.id, item.value, Date.now())
.run();
}
console.log(`Collected ${data.items.length} items`);
} catch (error) {
console.error('Failed to collect data:', error);
throw error;
}
},
};
wrangler.jsonc:
{
"triggers": {
"crons": ["*/15 * * * *"]
}
}
3. Daily Reports Generation
Every day at 8am UTC: Generate and email report
export default {
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
const report = await generateDailyReport(env.DB);
const fileName = `reports/${new Date().toISOString().split('T')[0]}.json`;
await env.MY_BUCKET.put(fileName, JSON.stringify(report));
ctx.waitUntil(sendReportEmail(report, env.RESEND_API_KEY));
console.log('Daily report generated and sent');
},
};
async function generateDailyReport(db: D1Database) {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const startOfDay = yesterday.setHours(0, 0, 0, 0);
const endOfDay = yesterday.setHours(23, 59, 59, 999);
const stats = await db
.prepare(`
SELECT
COUNT(*) as total_users,
COUNT(DISTINCT user_id) as active_users,
SUM(revenue) as total_revenue
FROM events
WHERE timestamp BETWEEN ? AND ?
`)
.bind(startOfDay, endOfDay)
.first();
return {
date: yesterday.toISOString().split('T')[0],
stats,
};
}
wrangler.jsonc:
{
"triggers": {
"crons": ["0 8 * * *"]
}
}
4. Cache Warming
Every hour: Pre-warm cache with popular content
export default {
async scheduled(controller: ScheduledController, env: Env): Promise<void> {
const popularPages = await env.DB
.prepare('SELECT url FROM pages ORDER BY views DESC LIMIT 100')
.all();
const requests = popularPages.results.map((page) =>
fetch(`https://example.com${page.url}`, {
cf: {
cacheTtl: 3600,
},
})
);
await Promise.all(requests);
console.log(`Warmed cache for ${popularPages.results.length} pages`);
},
};
wrangler.jsonc:
{
"triggers": {
"crons": ["0 * * * *"]
}
}
5. Monitoring & Health Checks
Every 5 minutes: Check system health
export default {
async scheduled(controller: ScheduledController, env: Env): Promise<void> {
const checks = await Promise.allSettled([
checkDatabaseHealth(env.DB),
checkAPIHealth(),
checkStorageHealth(env.MY_BUCKET),
]);
const failures = checks.filter((check) => check.status === 'rejected');
if (failures.length > 0) {
await sendAlert({
service: 'health-check',
failures: failures.map((f) => f.reason),
timestamp: new Date().toISOString(),
});
}
},
};
async function checkDatabaseHealth(db: D1Database): Promise<void> {
const result = await db.prepare('SELECT 1 as health').first();
if (!result || result.health !== 1) {
throw new Error('Database health check failed');
}
}
async function checkAPIHealth(): Promise<void> {
const response = await fetch('https://api.example.com/health');
if (!response.ok) {
throw new Error(`API health check failed: ${response.status}`);
}
}
async function checkStorageHealth(bucket: R2Bucket): Promise<void> {
const testObject = await bucket.get('health-check.txt');
if (!testObject) {
throw new Error('Storage health check failed');
}
}
wrangler.jsonc:
{
"triggers": {
"crons": ["*/5 * * * *"]
}
}
TypeScript Types
interface ScheduledController {
readonly cron: string;
readonly type: string;
readonly scheduledTime: number;
}
interface ExecutionContext {
waitUntil(promise: Promise<any>): void;
passThroughOnException(): void;
}
export default {
async scheduled(
controller: ScheduledController,
env: Env,
ctx: ExecutionContext
): Promise<void>;
}
Limits & Pricing
Limits
| Feature | Free Plan | Paid Plan |
|---|
| Cron triggers per Worker | 3 | Higher (check docs) |
| CPU time per execution | 10 ms (avg) | 30 seconds (default), 5 min (max) |
| Wall clock time | 30 seconds | 15 minutes |
| Memory | 128 MB | 128 MB |
Pricing
Cron triggers use Standard Workers pricing:
- Workers Paid Plan: $5/month required
- Requests: $0.30 per million requests (after 10M free)
- CPU Time: $0.02 per million CPU-ms (after 30M free)
Cron execution = 1 request
Example:
- Cron runs every hour (24 times/day)
- 30 days × 24 executions = 720 executions/month
- Average 50ms CPU time per execution
Cost:
- Requests: 720 (well under 10M free)
- CPU time: 720 × 50ms = 36,000ms (under 30M free)
- Total: $5/month (just subscription)
High frequency example:
- Cron runs every minute (1440 times/day)
- 30 days × 1440 = 43,200 executions/month
- Still under free tier limits
- Total: $5/month
Troubleshooting
Issue: Cron not executing
Possible causes:
- Changes not propagated yet (wait 15 minutes)
- Invalid cron expression
- Handler not exported correctly
- Worker not deployed
Solution:
npx wrangler deploy
Issue: Handler executes but fails
Possible causes:
- Uncaught error in handler
- CPU time limit exceeded
- Missing environment bindings
- Network timeout
Solution:
export default {
async scheduled(controller, env, ctx) {
try {
await yourTask(env);
} catch (error) {
console.error('Handler failed:', {
error: error.message,
stack: error.stack,
cron: controller.cron,
time: new Date(controller.scheduledTime),
});
ctx.waitUntil(sendAlert(error));
throw error;
}
},
};
Check logs in dashboard for error details.
Issue: Wrong execution time
Cause: UTC vs. local timezone confusion
Solution:
Convert your desired local time to UTC:
{
"triggers": {
"crons": ["0 17 * * *"]
}
}
Tools:
Issue: Local testing not working
Possible causes:
- Missing
--test-scheduled flag
- Wrong endpoint (should be
/__scheduled)
- Python Worker (use
/cdn-cgi/handler/scheduled)
Solution:
npx wrangler dev --test-scheduled
curl "http://localhost:8787/__scheduled?cron=0+*+*+*+*"
Production Checklist
Before deploying cron triggers to production:
Related Documentation
Last Updated: 2025-10-23
Version: 1.0.0
Maintainer: Jeremy Dawes | jeremy@jezweb.net