Back to Blog
Mar 4, 20264 min readOnuzulike Anthony

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.

InfrastructureCloudflareWorkersCronServerless

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

toml
# 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:

typescript
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:

typescript
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 }
  );
}
typescript
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

typescript
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:

typescript
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

bash
# 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:

typescript
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
  }
}