Cloudflare Workers cron trigger not running: 7 causes and fixes

You added a cron trigger to your Cloudflare Worker, deployed, and waited. Nothing happened.
Or it ran for a while, and then the job just stopped doing its work. Here are the causes, in the order worth checking.
- UTC
- the time zone every Cron Trigger runs in
- 15 min
- how long a trigger change can take to go live
- 10 ms
- CPU time per cron run on Workers Free
- 100
- past runs kept in the dashboard's event list
First check: the Trigger Events list in the dashboard
In the Cloudflare dashboard, open Workers & Pages, pick your Worker, then go to Settings.
Under Trigger Events, select View events. It keeps the 100 most recent cron runs.
No runs at all means the trigger never fired: start with causes 1 to 4. Runs with errors means it fired and failed: jump to causes 5 and 6.
1. The Worker has no scheduled() handler
A cron trigger doesn't call your fetch handler. It calls scheduled.
If your Worker only exports fetch, there is nothing for the trigger to run. Add the handler next to it:
// src/index.ts
export default {
async fetch(request, env, ctx) {
return new Response("ok");
},
// Without this, a cron trigger has nothing to call.
async scheduled(controller, env, ctx) {
console.log("cron fired:", controller.cron);
await runNightlyJob(env); // await it, so the run lasts as long as the job
},
};2. The cron isn't in your Wrangler config
Triggers live under [triggers] in wrangler.toml, or triggers in wrangler.jsonc.
# wrangler.toml
name = "nightly-jobs"
main = "src/index.ts"
[triggers]
crons = ["0 3 * * *"] # 03:00 UTC, every day
# wrangler.jsonc equivalent:
# "triggers": { "crons": ["0 3 * * *"] }Pick one place to manage them. Cloudflare's docs say that if a Worker is managed with Wrangler, its cron triggers should be managed only in the Wrangler config file.
So a trigger you added by hand in the dashboard is not a safe place to keep your schedule.
3. The trigger is on a different environment
If you deploy with --env production, check where your crons line lives.
A [triggers] block under [env.staging] only applies to staging. Cloudflare lets each environment set its own cron triggers.
Also check you're looking at the right Worker in the dashboard. Each environment you deploy shows up as its own Worker.
4. You just deployed: allow up to 15 minutes
Adding, changing or deleting a cron trigger can take up to 15 minutes to reach Cloudflare's network.
A */5 * * * * schedule that hasn't fired two minutes after deploy is not broken yet. Wait a quarter of an hour before you debug.
The schedule is in UTC, not your time zone
Cron Triggers run on UTC. So 0 3 * * * fires at 03:00 UTC, which may be the middle of your evening.
A job that "never runs" at 3am local time is often running right on time, just at a different hour.
5. The run hits the CPU time limit
Each cron run gets a CPU budget, and it's small on the Free plan.
- Workers Free: 10 ms of CPU time per run.
- Workers Paid: 30 seconds for schedules more often than hourly, and 15 minutes for schedules of an hour or more.
CPU time is time spent computing, not time spent waiting on a fetch. But a job that parses a big file or loops over many rows can burn through 10 ms fast.
A job that grew over time can start failing here with no code change at all. Check the Trigger Events list for errors.
6. The handler returns before the work is done
The runtime waits for the promise that scheduled() returns, up to 15 minutes.
Work you start without awaiting it, or without passing it to ctx.waitUntil(), is not part of that promise. It can be cut off when the handler returns.
Fix: await the job itself, and log a line when it finishes, not just when it starts.
ctx.waitUntil(), Cloudflare records the first one that fails as the run's status in Trigger Events. Any other failure can still show as a success.7. You've hit the cron trigger limit
Workers Free allows 5 cron triggers per account. Workers Paid allows 250.
If you have a few Workers each with their own schedule, a new one may be the one that doesn't fit.
The opposite problem: a cron you removed is still running
Commenting out the crons line does not remove the trigger.
To turn every trigger off, deploy with an empty list: crons = [].
How to test a Cloudflare Workers cron trigger locally
wrangler dev exposes a special route that fires your scheduled handler on demand:
# terminal 1 npx wrangler dev # terminal 2: fire the scheduled handler once curl "http://localhost:8787/cdn-cgi/local/scheduled?cron=0+3+*+*+*"
This proves the handler works. It doesn't prove the trigger is registered, so check Trigger Events after you deploy.
Stuck? Paste your config and entry file into this prompt:
My Cloudflare Worker has a cron trigger that isn't running. Help me find out why, one cause at a time. Here is my wrangler.toml (or wrangler.jsonc) and my Worker's entry file: [paste both here] Check these, in order, and tell me which one applies: 1. Does the default export include an async scheduled(controller, env, ctx) handler, not just fetch? 2. Is [triggers] crons defined for the environment I actually deploy (top level, or under [env.<name>])? Am I looking at the right Worker in the dashboard for that environment? 3. Did I also add or edit triggers in the Cloudflare dashboard? Cloudflare says a Wrangler-managed Worker should manage cron triggers only in the config file. 4. Is the cron expression valid, and what time does it fire in UTC versus my local time? 5. Could the job exceed the CPU limit for my plan (10 ms on Workers Free; 30 seconds, or 15 minutes for schedules of an hour or more, on Paid)? 6. Does the handler await all its work, or does it start promises it never awaits or passes to ctx.waitUntil? For each problem, show me the corrected code or config. Then tell me how to confirm the fix with wrangler dev and /cdn-cgi/local/scheduled, and where to read past runs in the dashboard.
How to get alerted when a Cloudflare cron stops running
The Trigger Events list only helps if you go and look. Most people look after something downstream has already gone wrong.
A heartbeat flips that around. The job reports in when it finishes, and you get an email when a report doesn't arrive on time.
In a Worker, that's one fetch at the end of scheduled():
// src/index.ts
export default {
async scheduled(controller, env, ctx) {
await runNightlyJob(env);
// Only reached if the job above finished without throwing.
await fetch("https://ingest.tellmewhendown.com/api/ingest/heartbeat", {
method: "POST",
headers: {
Authorization: `Bearer ${env.TMWD_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({ type: "nightly-job" }),
});
},
};
// Store the key as a secret, not in wrangler.toml:
// npx wrangler secret put TMWD_API_KEYThe ping sits after the work. A run that throws, runs out of CPU, or never fires all look the same from our side: a missed beat.
Vercel has its own version of these traps. See why your Vercel cron job is not running.
Every limit above comes from Cloudflare's Cron Triggers docs and Workers limits page.
Know when your Cloudflare cron goes quiet.
Join Tell Me When Down free and add one heartbeat to your scheduled() handler. If the trigger stops firing, a run hits its CPU limit, or the job throws, you get an email the first time a run goes missing.
spot something wrong or out of date? [email protected] — we'll fix it