Scheduled Workers with Cloudflare Cron Triggers
Setting up Cron Triggers in Workers, handling long-running tasks within the 30-second CPU limit, and patterns for cache warming and periodic data refresh.
Cloudflare Workers have a 30-second CPU limit per invocation. For periodic tasks — cache warming, recommendation pre-computation, stale data cleanup — Cron Triggers fire your Worker on a schedule without needing an external scheduler.
Cron Trigger Setup
# wrangler.toml
[triggers]
crons = [
"0 * * * *", # hourly - recommendation cache warm
"*/15 * * * *", # every 15 min - trending content refresh
"0 0 * * *", # daily - cleanup expired tokens
]In your Worker:
export default {
// Handle HTTP requests
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
return handleRequest(request, env);
},
// Handle scheduled events
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
ctx.waitUntil(handleScheduled(event.cron, env));
},
};
async function handleScheduled(cron: string, env: Env): Promise<void> {
switch (cron) {
case "0 * * * *":
await warmRecommendationCache(env);
break;
case "*/15 * * * *":
await refreshTrendingContent(env);
break;
case "0 0 * * *":
await cleanupExpiredTokens(env);
break;
default:
console.log(`Unknown cron: ${cron}`);
}
}The 30-Second CPU Limit
The 30-second limit applies to CPU time, not wall time. Network I/O (fetch, D1 queries, R2 reads) doesn't count. In practice:
- Database reads/writes: mostly I/O, count very little
- Serialization/deserialization: counts
- Sorting, scoring, ranking algorithms: counts
For recommendation warm-up across thousands of users, process in batches:
async function warmRecommendationCache(env: Env): Promise<void> {
// Get active users (watched something in the last 7 days)
const activeUsers = await getActiveUsers(env.DB);
console.log(`Warming cache for ${activeUsers.length} active users`);
// Process in batches of 20 to stay within CPU budget
const BATCH_SIZE = 20;
for (let i = 0; i < activeUsers.length; i += BATCH_SIZE) {
const batch = activeUsers.slice(i, i + BATCH_SIZE);
await Promise.all(batch.map(userId => warmUserCache(userId, env)));
}
}
async function warmUserCache(userId: string, env: Env): Promise<void> {
// Compute recommendations
const recs = await computeRecommendations(userId, env.DB);
// Store in KV with 1-hour TTL
await env.KV.put(
`recs:${userId}`,
JSON.stringify(recs),
{ expirationTtl: 3600 }
);
}Trending Content Refresh
async function refreshTrendingContent(env: Env): Promise<void> {
// Fetch trending from TMDB
const trending = await fetchTrendingFromTMDB(env.TMDB_API_KEY);
// Store in KV, keyed by content type
await Promise.all([
env.KV.put("trending:movie", JSON.stringify(trending.movies), {
expirationTtl: 900 // 15 min TTL matches cron interval
}),
env.KV.put("trending:tv", JSON.stringify(trending.tv), {
expirationTtl: 900
}),
env.KV.put("trending:anime", JSON.stringify(trending.anime), {
expirationTtl: 900
}),
]);
console.log(`Refreshed trending: ${trending.movies.length} movies, ${trending.tv.length} TV shows`);
}Cleanup Jobs
async function cleanupExpiredTokens(env: Env): Promise<void> {
// Delete stream tokens older than 1 day from D1
const result = await env.DB.prepare(`
DELETE FROM stream_tokens
WHERE created_at < datetime('now', '-1 day')
`).run();
console.log(`Cleaned up ${result.meta.changes} expired stream tokens`);
}Idempotency
Cron Triggers can fire multiple times in rare cases (network retries, region failover). Design handlers to be idempotent:
async function warmUserCache(userId: string, env: Env): Promise<void> {
// Check if cache is still fresh before recomputing
const existing = await env.KV.get(`recs:${userId}`, { type: "json", cacheTtl: 60 });
if (existing) {
// Already cached and fresh — skip recomputation
return;
}
const recs = await computeRecommendations(userId, env.DB);
await env.KV.put(`recs:${userId}`, JSON.stringify(recs), { expirationTtl: 3600 });
}Testing Cron Locally
# Trigger the scheduled event via wrangler
wrangler dev --test-scheduled
# In another terminal, POST to the test endpoint
curl "http://localhost:8787/__scheduled?cron=0+*+*+*+*"This fires your scheduled() handler with the specified cron pattern, letting you test without waiting for the real schedule.
Monitoring
Cloudflare's dashboard shows Cron Trigger execution history under Workers & Pages → your-worker → Triggers. For alerting on failures, log a structured event and create a Cloudflare Notification:
async function handleScheduled(cron: string, env: Env) {
const start = Date.now();
try {
await runCronTask(cron, env);
console.log(JSON.stringify({ event: "cron_success", cron, duration_ms: Date.now() - start }));
} catch (err) {
console.error(JSON.stringify({ event: "cron_error", cron, error: String(err) }));
throw err; // Re-throw so Cloudflare marks the invocation as failed
}
}