Cloudflare Workers Cron Triggers vs an external scheduler

Cron Triggers are free, live next to your Worker code and take one line of config. They are also UTC only, limited on the Free plan and quiet when something fails. Here is when they are the right tool, when an outside scheduler is, and how to combine the two.

CronEngine team

· 8 min read

This is not a "replace Cloudflare" article. If your scheduled work reads and writes KV, D1, R2 or a Queue, a Cron Trigger is usually the best place to start it from. The question is what you need around it: other timezones, retries, a history of runs, or a message when it stops. That is where the two approaches differ.

In short

  1. 1For light work inside Cloudflare, Cron Triggers are the simple, free choice. Use day names such as MON-FRI and remember it is UTC.
  2. 2Add a heartbeat at the end of the scheduled handler, so a run that fails or never happens reaches your inbox.
  3. 3Use an external scheduler when the job is not in Cloudflare, needs your timezone, retries or more than 5 triggers on the Free plan.

How Cron Triggers work

You list cron expressions in the Worker's Wrangler configuration and export a scheduled handler. Cloudflare calls that handler on schedule, without an HTTP request from anyone.

wrangler.toml TOML
[triggers]
# Every 15 minutes, and at 07:00 UTC on weekdays
crons = ["*/15 * * * *", "0 7 * * MON-FRI"]
src/index.js JavaScript
export default {
  async scheduled(controller, env, ctx) {
    // controller.cron is the expression that fired
    ctx.waitUntil(runJob(env, controller.cron));
  },
};

To try it locally, run wrangler dev and request http://localhost:8787/cdn-cgi/local/scheduled, which runs the scheduled handler once.

The limits

From Cloudflare's Workers limits and pricing pages, October 2026:

Workers FreeWorkers Paid
Cron Triggers per account5250
CPU time per run10 ms30 s, or 15 min for schedules of an hour or more
Wall time per run15 min15 min
PriceFreeFrom $5 a month per account

CPU time is the time your code actually computes. Waiting for a fetch or a database does not count, which is why 10 ms is enough for a run that mostly calls other services, and not enough for one that parses a large file.

Things that catch people out

  • Weekday numbers. Cloudflare counts 1 as Sunday and 7 as Saturday, so 1-5 is Sunday to Thursday. Write MON-FRI.
  • UTC only. There is no timezone setting, so a local time shifts by an hour twice a year.
  • Changes are not instant. A new or edited schedule can take up to 15 minutes to reach every data centre.
  • One account, one pool. The 5 or 250 triggers are shared by every Worker in the account.
  • Quiet failures. A run that throws shows up in Trigger Events and Workers Logs, which keep a history but do not message you. Somebody has to look.

When Cron Triggers are the right choice

  • The work happens inside Cloudflare: clearing KV keys, rolling up D1 rows, sending a batch to a Queue, pruning R2.
  • It is light, or you are on the Paid plan anyway.
  • A run that is a little late, or occasionally fails until the next one, does no harm.
  • UTC is fine for the schedule.

In that case, keep them. Adding a heartbeat, below, closes the one real gap: knowing when they stop.

When an external scheduler fits better

  • The job is not in Cloudflare. If the work is an API call to your own server, a Worker in between only adds a hop. Let the scheduler call the server directly.
  • The time matters to people. "Every weekday at 09:00 in Amsterdam" should stay 09:00 after the clocks change.
  • Failures need a response. Retries, a record of each run's status and response, and an email or Slack message when it fails.
  • You need more than 5 schedules and do not want the Paid plan for that alone.
  • Other people change the schedule. In a dashboard, without a deploy and without waiting for it to propagate.

Side by side

Cron TriggersExternal scheduler
RunsWorker code onlyAny URL, including a Worker
TimezoneUTCUsually your choice
Changing a scheduleDeploy, up to 15 min to take effectEdit in a dashboard
HistoryLast 100 runs, plus Workers LogsStatus, timing and response per run
AlertsBuild them yourselfBuilt in
CostIncluded in WorkersFree tier, then a monthly plan

Keep Cron Triggers, add a heartbeat

A heartbeat is a URL your job calls when it finishes. The monitoring service expects that call on your schedule and emails you when it does not arrive, or when the job reports a failure. It catches the cases nothing inside the Worker can: a trigger that was removed in a deploy, a run that ran out of CPU time, an exception nobody saw.

Create a heartbeat in CronEngine with the same schedule as the trigger, store its URL as a secret called HEARTBEAT_URL with wrangler secret put HEARTBEAT_URL, and wrap the job:

src/index.js JavaScript
export default {
  async scheduled(controller, env, ctx) {
    ctx.waitUntil(withHeartbeat(env, () => runJob(env)));
  },
};

async function withHeartbeat(env, job) {
  // A ping that fails must never stop or fail the job itself
  const ping = (suffix = '') => fetch(env.HEARTBEAT_URL + suffix).catch(() => {});

  await ping('/start');
  try {
    await job();
  } catch (error) {
    await ping('/fail');
    throw error;
  }
  await ping();
}

The heartbeat URL looks like https://cronengine.com/ping/hb_YOUR_TOKEN. The /start call is optional, and lets the run's duration be measured. The free plan includes 2 heartbeats.

Call a Worker from outside

When you want the schedule outside Cloudflare but the code inside, give the Worker a fetch handler that checks a secret and runs the same function. Answer with an error status when the job fails, so the scheduler records it as a failure.

src/index.js JavaScript
export default {
  async fetch(request, env) {
    // Only the scheduler knows this secret
    if (request.headers.get('Authorization') !== `Bearer ${env.CRON_SECRET}`) {
      return new Response('Unauthorized', { status: 401 });
    }

    try {
      await runJob(env);
      return new Response('ok');
    } catch (error) {
      return new Response(String(error), { status: 500 });
    }
  },
};

Then create a cron job in CronEngine for https://your-worker.your-account.workers.dev/ with Bearer authentication and the same secret. It runs in your account's timezone, keeps every response, retries failed runs on paid plans, and emails you when the job starts failing. The free plan covers 3,000 runs a month, one job every 15 minutes.

Questions

Are Cloudflare Cron Triggers free?

Yes, on both plans. The Free plan allows 5 Cron Triggers per account with 10 ms of CPU time per run. The Workers Paid plan, from $5 a month, allows 250 per account with far more CPU time.

Which timezone do Cron Triggers use?

Always UTC. A trigger for 09:00 in Brussels has to be written as 08:00 or 07:00 UTC depending on the time of year, and changed when the clocks go forward or back.

Why does 1-5 not mean Monday to Friday?

Cloudflare numbers weekdays from 1 for Sunday to 7 for Saturday, unlike most cron systems where 0 is Sunday. Use the names instead, MON-FRI, which Cloudflare accepts in any case.

How long until a new or changed Cron Trigger runs?

Cloudflare says changes can take several minutes, up to 15, to reach its whole network. A schedule changed just before it was due may still fire once on the old one.

How do I see whether a scheduled run worked?

In the dashboard, open the Worker, go to Settings and look at Trigger Events. Cron Events keeps the 100 most recent runs. Workers Logs keeps them longer. To be told about a failure, add a heartbeat or another alert yourself.

Can an external scheduler call my Worker?

Yes. Add a fetch handler that checks a secret header and runs the same code as the scheduled handler, then point the scheduler at the Worker URL. On the Free plan that request still has 10 ms of CPU time.

Spotted a mistake, or something that behaves differently for you? Tell us and we will update the guide.

Share X LinkedIn Reddit