Cloudflare logo

How to schedule Cloudflare Workers with Cron Triggers

Cloudflare Workers can run on a schedule with Cron Triggers: you list cron expressions in your Wrangler configuration, add a scheduled() handler, and Cloudflare invokes it at the right time. No server, no container, and the same code can also answer HTTP requests.

This guide covers the configuration, the handler, local testing, the limits on Free and Paid plans, and how to trigger the same Worker over HTTP when you need more control over scheduling.

Short answer

Add [triggers] crons = ["0 * * * *"] to wrangler.toml (or "triggers": { "crons": [...] } in wrangler.jsonc), export a scheduled(controller, env, ctx) handler, and deploy. Triggers run in UTC and can take up to 15 minutes to update after a deploy. The Free plan allows 5 triggers per account with 10 ms of CPU time per run.

Add a Cron Trigger

In wrangler.toml:

name = "inventory-sync"
main = "src/index.ts"
compatibility_date = "2026-10-01"

[triggers]
crons = ["0 * * * *", "30 3 * * 1"]   # hourly, and Mondays at 03:30 UTC

Or in wrangler.jsonc:

{
  "name": "inventory-sync",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "triggers": {
    "crons": ["0 * * * *", "30 3 * * 1"]
  }
}

Expressions use the usual five fields and also accept a few extensions: 59 23 LW * *, for example, runs at 23:59 on the last weekday of each month. Check plain expressions with the cron analyzer.

Two configuration details save a lot of confusion:

  • To remove triggers, set crons = []. If you simply delete the key, the existing triggers stay in place after the next deploy.
  • Environments have their own triggers. Put them under [env.staging.triggers] (or env.staging.triggers in JSON) if staging should run on a different schedule, or not at all.

Write the scheduled() handler

A Worker can export several handlers. Cron Triggers call scheduled, which receives a controller with the expression that fired:

// src/index.ts
export default {
  async scheduled(controller, env, ctx) {
    switch (controller.cron) {
      case '0 * * * *':
        await syncInventory(env);
        break;
      case '30 3 * * 1':
        await sendWeeklyReport(env);
        break;
    }
    console.log('Ran', controller.cron, 'scheduled for', new Date(controller.scheduledTime).toISOString());
  },
} satisfies ExportedHandler<Env>;

controller.cron matches the string in your configuration character for character, so copy it exactly. controller.scheduledTime is the planned time in milliseconds since the epoch (UTC), which makes a good idempotency key: if the same run is ever processed twice, "sync for 2026-10-27T09:00Z" is still one run.

The runtime waits for the handler's promise for up to 15 minutes. Work started with ctx.waitUntil() also counts, and the first promise passed to waitUntil that fails decides the status shown under the Worker's past cron events.

Test it locally

Start the dev server with scheduled testing enabled, then fire a trigger with curl:

npx wrangler dev --test-scheduled

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

Spaces in the expression become +. You can add &time= with a timestamp in milliseconds to simulate a specific scheduledTime. Older tutorials mention /__scheduled; that path is no longer documented. Since October 2026, recent Wrangler versions can also fire crons from the Local Explorer at /cdn-cgi/local/explorer. Local development never runs crons on its own.

Limits on Free and Paid plans

Workers FreeWorkers Paid
Cron Triggers5 per account250 per account
CPU time per run10 ms30 s if the interval is under 1 hour; 15 min if it is 1 hour or more
Wall time per run15 min15 min
Time zoneUTC only
Applying changesUp to 15 minutes after deploy

Note that the trigger limit is per account, not per Worker: on the Free plan, five Workers with one trigger each use up the allowance. CPU time only counts actual computation, so a run that mostly waits on fetch() calls uses far less than its wall time.

The dashboard keeps the 100 most recent cron events for each Worker; turn on Workers Logs for a longer history. Cron-triggered Workers run on underutilized machines, and an account-level Green Compute setting restricts them to data centers powered by renewable energy.

Trigger the same Worker over HTTP

Cron Triggers are configured at deploy time. Sometimes you want something else: more schedules than the Free plan's five, a schedule you can change without redeploying and waiting for propagation, one place to see every job across Cloudflare and other platforms, or simply a way to run the job on demand. Add a fetch handler that runs the same function, protected by a secret, and call it from an external scheduler.

Diagram of a Worker with two entry points: a Cron Trigger calls scheduled(), a Cronit job calls fetch() with a secret header, and both call the same runJob function.
Both entry points call the same function, so you can switch triggers without touching the job itself.
// src/index.ts
async function runJob(env: Env) {
  await syncInventory(env);
}

export default {
  async scheduled(controller, env, ctx) {
    ctx.waitUntil(runJob(env));
  },

  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (request.method !== 'POST' || url.pathname !== '/run') {
      return new Response('Not found', { status: 404 });
    }
    if (request.headers.get('authorization') !== `Bearer ${env.CRON_SECRET}`) {
      return new Response('Unauthorized', { status: 401 });
    }
    await runJob(env);
    return Response.json({ ok: true });
  },
} satisfies ExportedHandler<Env>;

Store the secret with Wrangler rather than in the config file:

openssl rand -hex 32
npx wrangler secret put CRON_SECRET

Then create the job in Cronit: method POST, URL https://inventory-sync.YOUR-SUBDOMAIN.workers.dev/run (or your custom domain), an Authorization: Bearer … header with the secret, and the schedule you need. Because the handler awaits the job and returns an error status if it throws, each call's status in the execution logs tells you whether the run succeeded.

Two things to keep in mind. HTTP requests have their own CPU limits, separate from cron runs, and on the Free plan both are tight, so this approach does not make a heavy job cheaper. And for work that cannot finish within a single invocation, have the handler send messages to a Queue or start a Workflow instead of doing everything inline.

FAQ

What time zone do Cloudflare Cron Triggers use?
UTC, always. Convert local times, and remember the offset changes with daylight saving time.
Why didn't my new cron schedule run right after deploying?
Changes to Cron Triggers can take up to 15 minutes to reach Cloudflare's network. Check the Worker's settings to confirm the trigger is listed.
How many Cron Triggers can I have?
5 per account on Workers Free and 250 per account on Workers Paid.
Can one Worker have several schedules?
Yes. List several expressions in crons and branch on controller.cron in the handler.

Run online cronjobs without server setup

Plan, schedule and run periodic tasks and scripts, from your browser with the first online cronjob platform.

Try for free