cronuru
Guide

Cloudflare Workers Cron Triggers — A Complete Guide

Cloudflare Workers cron triggers run a Worker on a recurring schedule at the edge — no servers, no orchestrator, fire-and-forget. This guide covers the wrangler configuration, the `scheduled()` handler signature, local testing, limits, and how to pair cron triggers with KV, D1, and R2 for real workloads.

Updated

Wrangler configuration

Add a [triggers] block (or triggers object in wrangler.jsonc) to your Worker’s config:

# wrangler.toml
name = "my-worker"
main = "src/index.js"
compatibility_date = "2026-05-24"

[triggers]
crons = ["*/5 * * * *"]
// wrangler.jsonc
{
  "name": "my-worker",
  "main": "src/index.js",
  "compatibility_date": "2026-05-24",
  "triggers": {
    "crons": ["*/5 * * * *"]
  }
}

Deploy with wrangler deploy. The trigger is registered automatically — no separate “create cron job” step. You can confirm in the dashboard at Workers & Pages → your worker → Triggers.

The cron expression is 5-field cron. Cloudflare supports the standard Unix syntax plus several Quartz-style extensions — L (last), W (nearest weekday), and # (nth weekday) — so 59 23 LW * * (last weekday of the month) is valid here even though it isn’t in plain Unix cron. All times are UTC — there’s no per-worker timezone option.

The scheduled handler

Export a scheduled handler from your Worker:

// src/index.js
export default {
  async scheduled(controller, env, ctx) {
    // controller.scheduledTime — number, ms since epoch, when this fire was scheduled
    // controller.cron          — string, the cron pattern that fired
    console.log(`Fired ${controller.cron} at ${new Date(controller.scheduledTime).toISOString()}`);
    await doWork(env);
  },

  // Optional — HTTP fetch handler for non-cron requests
  async fetch(request, env) {
    return new Response("Hello from a worker");
  },
};

The handler signature:

  • controller.scheduledTime — milliseconds since epoch when the fire was scheduled. Useful when you need “what time was this supposed to run” (vs. Date.now() which is when it actually started).
  • controller.cron — the cron pattern that triggered this fire. Use it to branch logic when you have multiple crons.
  • env — your worker’s bindings (KV namespaces, D1 databases, R2 buckets, secrets).
  • ctx.waitUntil(promise) — extends the Worker’s lifetime to wait for the promise. Use for fire-and-forget work you want to complete but don’t need to return from.

TypeScript variant:

// src/index.ts
export interface Env {
  MY_KV: KVNamespace;
  MY_DB: D1Database;
}

export default {
  async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) {
    await env.MY_KV.put("last-run", new Date().toISOString());
  },
} satisfies ExportedHandler<Env>;

Multiple crons per worker

A single Worker can have multiple cron triggers (the 5-per-account Free / 250-per-account Paid limit is across your whole account, not per Worker):

[triggers]
crons = [
  "*/5 * * * *",     # every 5 minutes
  "0 0 * * *",       # daily at midnight UTC
  "0 0 * * 1",       # weekly on Monday
]

The same scheduled() handler is called for all of them. Use controller.cron to dispatch:

export default {
  async scheduled(controller, env, ctx) {
    switch (controller.cron) {
      case "*/5 * * * *":
        await refreshCache(env);
        break;
      case "0 0 * * *":
        await dailyReport(env);
        break;
      case "0 0 * * 1":
        await weeklyCleanup(env);
        break;
    }
  },
};

For more than a handful of distinct schedules, split into separate Workers. The account-wide cap is 5 triggers on Free and 250 on paid, but well before that limit the cognitive load of one big handler with many branches usually outweighs the deployment overhead of splitting.

Local testing

Cron handlers don’t fire on a schedule in wrangler dev. Instead, run the dev server and hit its /cdn-cgi/handler/scheduled route to invoke the handler manually:

wrangler dev

Then trigger the handler with an HTTP request to that path:

curl "http://localhost:8787/cdn-cgi/handler/scheduled?cron=*+*+*+*+*"

The cron query parameter URL-encodes the cron expression (spaces as +). This invokes scheduled() with controller.cron set to the value you passed. (The older wrangler dev --test-scheduled + /__scheduled route still works if you prefer it.)

You can also override the scheduled time with time (epoch milliseconds):

curl "http://localhost:8787/cdn-cgi/handler/scheduled?cron=*/5+*+*+*+*&time=1774051200000"

This is the cleanest way to test cron handlers — no waiting for a real schedule to fire, no deploying to production. Wire it into your test suite to assert the handler does what you expect for each cron pattern.

Logging and observability

console.log / console.error in the handler appear in:

  • Real-time logs via wrangler tail while the Worker is running
  • The Cloudflare dashboard under Workers & Pages → your worker → Logs
  • Workers Analytics Engine (paid, if you’ve wired it up)

For production scheduled work, ship structured logs to an external system. Cloudflare’s built-in log retention is short (hours, not days). Common patterns:

  • console.log(JSON.stringify({ event, ...details })) then read via wrangler tail | jq for ad-hoc inspection
  • Use the Logpush API to stream logs to S3, R2, or another sink
  • Tail-style integration with Datadog, Honeycomb, or Logflare

For metrics, Cloudflare automatically reports per-Worker invocations, errors, and duration. To track per-cron-pattern metrics, use Workers Analytics Engine and emit a data point on each schedule fire with controller.cron as a dimension.

Limits

Cron triggers per account (not per Worker):

  • Free plan: 5
  • Paid plan: 250

CPU time per Cron Trigger:

  • Free plan: 10 ms (same as HTTP-triggered Workers)
  • Paid plan: 30 seconds for schedules more frequent than hourly; 15 minutes for hourly-or-longer intervals

Wall-clock duration for a Cron Trigger is capped at 15 minutes — a handler can wait on I/O within that window, but the CPU-time limits above still apply to actual compute.

Schedule precision:

  • Minimum granularity: 1 minute
  • Cloudflare fires the handler “near” the scheduled time — not to-the-second precision. Expect a few seconds of jitter.

For longer-running scheduled work, the standard pattern is to use the cron trigger to enqueue work into a queue (Cloudflare Queues, SQS, etc.) that’s processed by a separate, longer-running consumer.

Pairing with KV, D1, R2

Cron triggers are most useful when paired with Workers’ other primitives.

Workers KV — distributed lock to prevent overlap:

async scheduled(controller, env, ctx) {
  const lockKey = "cron-lock";
  const existing = await env.MY_KV.get(lockKey);
  if (existing) {
    console.log("Previous run still active, skipping");
    return;
  }
  // Lock with 5-minute TTL (slightly longer than expected runtime)
  await env.MY_KV.put(lockKey, Date.now().toString(), { expirationTtl: 300 });
  try {
    await doWork(env);
  } finally {
    await env.MY_KV.delete(lockKey);
  }
}

KV’s eventual consistency means this lock isn’t bulletproof under contention — for strict concurrency control, use D1 with a transactional update.

D1 — periodic database maintenance:

async scheduled(controller, env, ctx) {
  // Delete soft-deleted rows older than 30 days
  await env.MY_DB.prepare(`
    DELETE FROM users WHERE deleted_at IS NOT NULL AND deleted_at < ?
  `).bind(Date.now() - 30 * 86400 * 1000).run();
}

R2 — periodic snapshots or backups:

async scheduled(controller, env, ctx) {
  const snapshot = await generateSnapshot(env);
  const key = `snapshots/${new Date().toISOString()}.json`;
  await env.MY_BUCKET.put(key, JSON.stringify(snapshot));
}

The common thread: cron triggers are great for “do something periodically against my stateful primitives.” For external HTTP work (calling third-party APIs, dispatching to other systems), they’re also fine — just add error handling and consider a DLQ pattern using Queues for retries.

Frequently asked questions

What cron syntax do Cloudflare Workers cron triggers use?
Five fields — minute, hour, day-of-month, month, day-of-week. Beyond the standard `*`, `,`, `-`, `/` operators, Cloudflare also supports several Quartz-style extensions: `L` (last), `W` (nearest weekday), and `#` (nth weekday) — e.g. `59 23 LW * *`. Minimum granularity is 1 minute. Always UTC — no per-worker timezone option.
How do I test a cron trigger locally?
Run `wrangler dev`, then hit the `/cdn-cgi/handler/scheduled` route to fire the handler manually: `curl "http://localhost:8787/cdn-cgi/handler/scheduled?cron=*+*+*+*+*"`. Add `&time=` to override the scheduled time. (The older `wrangler dev --test-scheduled` + `/__scheduled` route still works too.) Spaces in the cron string must be URL-encoded as `+`.
How many cron triggers can I have?
The limit is per account, not per Worker: 5 cron triggers on the Free plan and 250 on paid plans. Each trigger has its own cron pattern; the `scheduled()` handler is called for all of them and `controller.cron` tells you which one fired.
How long can a cron-triggered Worker run?
CPU time per Cron Trigger is 10 ms on the Free plan (same as HTTP-triggered Workers) and, on paid plans, 30 seconds for schedules more frequent than hourly or 15 minutes for schedules at hourly-or-longer intervals. Wall-clock duration is capped at 15 minutes regardless.
How do I avoid the cron firing while a previous run is still going?
Cloudflare doesn't provide built-in concurrency control. Use a Workers KV key as a lock — set a key with a TTL when the handler starts, check it before doing work, and delete it on completion. Or use D1 with a transactional lock row.