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.tomlper Worker. Eight Workers means eightwrangler.tomlfiles 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 agit grepor 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, runwrangler 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 write0 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:
- The schedule lives in Crontap, not in
wrangler.toml. Cadence changes are saves; deploys are deploys. - 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.
- The Worker stops carrying a
scheduledhandler. It keeps itsfetchhandler, 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.
- URL. Paste the Worker URL plus the cron path, e.g.
https://kv-warmer.your-account.workers.dev/run. - Method.
GETif your route accepts GET,POSTif it expects a body. - Headers. Add
Authorization: Bearer <your WORKER_SECRET>. Crontap stores the value encrypted; you do not see the plaintext again after saving. - Cadence. Type plain English ("every 5 minutes") or paste a cron expression. Crontap previews the next 5 fires inline.
- 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.
- 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
fetchhandler, 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.tomldoes 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 asfetchwithout 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
- Cron jobs for Cloudflare Workers. The use-case-first guide for Workers users wiring up an external scheduler.
- Cloud Run cron without Cloud Scheduler. Sibling cron-from-the-cloud-platform shape, GCP edition.
- API health checks use case. The category spoke for the per-minute Worker healthcheck shape.
