Back to blog

Alternatives · Dec 18, 2025

Cloudflare Workers Cron Triggers vs external cron: when to keep wrangler.toml

Cron Triggers are clean when a single Worker owns one schedule. Once you have several Workers across several zones, the schedules live in eight wrangler.toml files and there is no central view. Here is the external-cron pattern teams use for one dashboard, per-IANA timezone, retries, and cadence changes without a deploy.
crontap.com / blog
Cron Triggers ship in wrangler.toml and run inside the Worker isolate. They are clean when one Worker owns one schedule. Once you have several Workers across several zones, the schedule lives in eight wrangler.toml files and there is no central view. Here is the external-cron pattern that gives you one dashboard, per-IANA timezone, retries on 5xx, and a cadence change without a deploy.

You shipped a handful of Cloudflare Workers across three zones. One warms a KV cache every 5 minutes. One pings a healthcheck every minute. One sends a daily report at 08:00 Europe/Berlin. All three have [triggers] crons = [...] in their wrangler.toml, and all three were a five-line change at the time. Three months in, the team has 8 Workers, the schedules are scattered across 8 wrangler files, and "show me everything that runs on a schedule" is a git grep. The schedules work. The dashboard does not.

If you want the short version: keep your Workers, expose the work behind a fetch route, check a bearer header, and point Crontap at the *.workers.dev URL. You get one dashboard across every Worker in every zone (and every non-Worker target you happen to call), per-IANA timezone per schedule, automatic retries on 5xx, and a cadence change is a save instead of a wrangler deploy.

What Cron Triggers do

Cloudflare's Cron Triggers are the in-Worker scheduler. You declare the schedule in wrangler.toml:

name = "kv-warmer"
main = "src/index.ts"
compatibility_date = "2026-01-01"

[triggers]
crons = ["*/5 * * * *"]

And export a scheduled handler:

export default {
  async scheduled(event, env, ctx) {
    await warmCache(env.KV);
  },
};

When the trigger fires, Cloudflare runs your handler in the same isolate the Worker uses for fetch. It is a clean shape when the schedule is the Worker's primary purpose. As of 2026, Cron Triggers support 1-minute minimum cadence, the same floor as Crontap Pro.

Cadence is no longer the wedge. The wedge is what happens when one Worker becomes eight Workers becomes a multi-zone account.

The friction shows up per Worker, per zone, per environment

The friction is not in writing the first Cron Trigger. It is in living with several of them.

  • Schedule lives in wrangler.toml per Worker. Eight Workers means eight wrangler.toml files with their own [triggers] blocks. There is no central view that says "across all my Workers, here are the schedules and when they fire next". Auditing is a git grep or a Cloudflare dashboard click-through, one Worker at a time.
  • Cadence changes ship through deploy. Want to move the cache warm from every 5 minutes to every 2 minutes for a week of high traffic? You edit wrangler.toml, run wrangler deploy, and the change ships with whatever else is in your branch. Reverting is another deploy. The schedule and the code release are coupled.
  • No per-Worker timezone in the trigger. Cron Triggers run in UTC. If you want a schedule that fires at "08:00 Europe/Berlin year-round", you do the DST math yourself and write 0 7 * * * in winter, 0 6 * * * in summer, and update twice a year. Most teams write 0 7 * * * and shrug.
  • Free-plan subrequest limits. The free Workers plan caps subrequests per invocation. A Cron Trigger that fans out to many APIs can hit the budget; an external trigger that calls one Worker, which then fans out, has the same per-invocation cap but is easier to split across multiple Workers because the schedule layer does not care which Worker it fires.
  • Multi-zone audit story. Workers under different Cloudflare accounts (one per Zone, common for agency setups or multi-brand orgs) live in different dashboards. There is no single Workers UI that says "here are the schedules across these three zones".

Each of those is small in isolation. They add up to "we have a schedules problem and the schedules tab is eight tabs".

The pattern: external cron + Worker URL

Workers expose fetch routes natively. Crontap calls those URLs on a schedule and the Worker does its work. The contract is one HTTPS request per fire with a bearer header.

Crontap (cron)  →  HTTPS GET  →  https://kv-warmer.your-account.workers.dev/run  →  the actual work

Three things change:

  1. The schedule lives in Crontap, not in wrangler.toml. Cadence changes are saves; deploys are deploys.
  2. The dashboard is one tab across every Worker in every zone, plus every non-Worker target (Lambda Function URL, Cloud Run service, Vercel API route) you happen to schedule.
  3. The Worker stops carrying a scheduled handler. It keeps its fetch handler, which is what most Workers already had.

Step 1: Expose the work behind a fetch route

If your Worker already serves fetch, add a route for the cron trigger. If it was scheduled-only, refactor the body of scheduled into a function and call it from fetch:

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (url.pathname === "/run") {
      return runCronWork(request, env);
    }
    return new Response("not found", { status: 404 });
  },
};

The route name is your call (/run, /cron, /__cron/warm-cache). Pick one that is not on a customer-facing path so a curious browser does not stumble onto it. The point is: the schedule's work is now an HTTP route.

Step 2: Enforce a bearer header

Pick a strong random secret:

openssl rand -base64 32

Store it as a Worker secret:

wrangler secret put WORKER_SECRET

Check it on every request to the cron route:

async function runCronWork(request, env) {
  const auth = request.headers.get("authorization");
  if (auth !== `Bearer ${env.WORKER_SECRET}`) {
    return new Response("unauthorized", { status: 401 });
  }

  await warmCache(env.KV);
  return new Response("ok");
}

The bearer rides in the Authorization header. The token never lands in wrangler tail request URLs, and the Worker route is safe to expose because anyone who hits it without the header gets a 401.

Step 3: Store the bearer in Crontap

Create a new schedule in Crontap.

  1. URL. Paste the Worker URL plus the cron path, e.g. https://kv-warmer.your-account.workers.dev/run.
  2. Method. GET if your route accepts GET, POST if it expects a body.
  3. Headers. Add Authorization: Bearer <your WORKER_SECRET>. Crontap stores the value encrypted; you do not see the plaintext again after saving.
  4. Cadence. Type plain English ("every 5 minutes") or paste a cron expression. Crontap previews the next 5 fires inline.
  5. Timezone. Pick the IANA zone that matches the schedule's intent. Europe/Berlin for the morning report, UTC for cache warming, America/New_York for a market-open ping.
  6. Failure alerts. Add an integration: email / webhook (Slack / Discord / Telegram). Crontap fires on 4xx and 5xx with the response body and timing in the payload.

Press Perform test to fire a real request before you trust the cadence. If the Worker returns 200, you are done.

Fix this in 60 seconds with Crontap. Free tier available. No credit card. Schedule your first job →

Worked example: warm KV cache every 5 minutes in Europe/Berlin

A small media team runs a Worker that fronts a CMS and serves the homepage from KV. Every 5 minutes, the Worker re-fetches the homepage HTML from origin and writes it back to KV so the cache never goes stale. The original setup was a Cron Trigger:

[triggers]
crons = ["*/5 * * * *"]
export default {
  async scheduled(event, env, ctx) {
    const html = await fetchOrigin();
    await env.KV.put("homepage", html, { expirationTtl: 600 });
  },
};

It worked. Cadence was fine. The pain was elsewhere: the team also has a daily Worker that posts the morning report at 08:00 Europe/Berlin, and the DST swap caught them twice. Plus a per-customer Worker that pings a healthcheck endpoint, and a third Worker that fans out to 5 third-party APIs and occasionally hits the free-plan subrequest cap.

The new shape: every Worker keeps a fetch route at /run, every Worker checks the same WORKER_SECRET, and Crontap holds the schedules. The cache warmer fires */5 * * * * UTC. The morning report fires 0 8 * * * in Europe/Berlin (DST handled by Crontap; no manual swap). The healthcheck fires * * * * * per minute. The fan-out Worker fires once on cadence and the Worker chooses how to spread its subrequests.

The team's audit view became one Crontap tab. The schedules-tab problem went away.

Multi-zone, multi-account, one tab

This is the part that is hard to feel until you have several zones. Cloudflare's Workers UI is account-scoped. If you run an agency with one Cloudflare account per client, or a multi-brand org with one zone per brand, "show me all the schedules across all clients" is opening tabs.

Crontap is not bound to Cloudflare at all. Each schedule has a URL, a header, a cadence, and an IANA timezone, and they sit next to each other in one list. We have seen teams with 12 Workers across 4 Cloudflare accounts run all 12 schedules in one Crontap dashboard, with a single Slack channel as the failure routing target.

The same Crontap account can also schedule the team's Vercel API routes, Cloud Run jobs, and Lambda Function URLs without caring that they live on different platforms. The shape is just "URL on a schedule"; the platform is whatever happens to host the URL.

Migration is incremental. Pick one Worker, swap scheduled for a fetch route, recreate the schedule in Crontap, leave the Cron Trigger in wrangler.toml for a week of double-firing if your work is idempotent, then remove the [triggers] block on the next deploy.

When Cron Triggers are still the right call

External cron is a shape, not a religion. Cron Triggers are the right answer when:

  • The Worker exists exclusively to be fired on a schedule. There is no fetch handler, no public URL, no need for one. A Cron Trigger keeps the Worker tight and the schedule next to the code.
  • You want the schedule in version control alongside the Worker. For some teams (and some compliance regimes) "the schedule lives in our repo" is itself a feature. wrangler.toml does that; Crontap does not.
  • The Worker uses Durable Objects or Queues that benefit from running inside the Workers isolate without an HTTP hop. Cron Triggers fire scheduled, which gets the same context as fetch without crossing the request boundary.
  • You are on a free Workers plan and your subrequest budget per invocation is genuinely tight. Cron Triggers and external HTTPS calls share the same per-invocation subrequest cap, so external cron does not solve a subrequest-budget problem on its own; the workaround is to split the work across Workers.

For everything else (and that is most schedules in a multi-Worker setup), the external pattern reads cleaner.

FAQ

What's the shortest interval Crontap supports?

Every 1 minute on Pro. Free tier available for slower cadences. Cloudflare Cron Triggers are also 1-minute minimum as of 2026. Cadence is not the wedge; the dashboard, timezone, and cadence-change-without-deploy story is.

Will Crontap retry a failed Worker run?

Yes. Crontap auto-retries on 5xx responses (a Worker that briefly returned 502 because an origin was slow, etc.) and alerts you on final failure. Cron Triggers do not retry scheduled failures by default; the next fire just runs whenever the next fire is.

Can I keep some Cron Triggers and use Crontap for the rest?

Yes. Mix freely. Workers that exist only to run on a schedule and never serve fetch are a clean fit for Cron Triggers. Workers that already serve fetch and have a schedule layered on top are cleaner with the external pattern. There is no conflict; you can move workloads between them as the dashboard story calls for.

Does this affect Worker pricing?

Cron Triggers and external HTTPS calls both count as Worker requests. The pricing is the same per invocation regardless of who initiated it. The wedge is not the per-Worker dollar; it is the cross-Worker dashboard and the cadence-change-without-deploy story.

How do I handle DST in Europe/Berlin?

You do not. Crontap stores timezone per schedule. Europe/Berlin means Europe/Berlin always; DST is handled automatically by the scheduler. You set it once and the spring/fall swaps just happen.

Can I use this with a custom domain instead of workers.dev?

Yes. If your Worker is bound to a custom route (e.g. cron.example.com/run), point Crontap at that URL instead. Auth and cadence work the same way; the only difference is the hostname Crontap calls.

References

Related on Crontap

From the blog

Read the blog

Guides, patterns and product updates.

Tutorials on scheduling API calls, webhooks and automations, plus deep dives into cron syntax, timezones and reliability.

Alternatives

Vercel Cron every minute: beating the Hobby hourly limit

Vercel Cron caps Hobby at hourly cadence and 5 jobs, and ties every change to a redeploy. Here is the external cron pattern teams use to ship per-minute schedules, per-IANA timezones, and one dashboard across projects without paying $20/mo per user for Pro.

Alternatives

Cloud Run cron without Cloud Scheduler

Cloud Scheduler costs $0.10 per job per month after the first 3 and asks for OIDC plus IAM bindings on every target. Here is the IAM-free pattern Cloud Run teams use to fire their .run.app URLs on a clock with one bearer token and one dashboard across every GCP project.

Alternatives

Heroku Scheduler alternative: any cron expression without the add-on

Heroku Scheduler caps you at three cadences and account-wide UTC, and spins a one-off dyno per run. Here is the external cron pattern that gives you any cron expression, per-schedule timezones, and zero per-execution dyno spin-up cost.

Guides

Running an OpenAI sentiment pipeline on a real scheduler

OpenAI batch work needs a clock, not a user session. Here is the scheduled HTTP-route pattern teams use to drain LLM batches at a sustainable rate inside OpenAI's rate limits, with per-task failure alerts.

Reference

Cron syntax cheat sheet with real-world examples

Cron syntax without the math. Every pattern you're likely to reach for (every 5 minutes, weekdays, business hours, first of the month), with a practical example and a link to a free debugger.