Scheduled tasks aren't running
Diagnose a stalled hourly maintenance tick: missing cron triggers, an unreachable scheduled() handler, and tasks that fail every hour.
#Scheduled backups stopped, or automation flows stopped firing
#Problem
Something that should happen on its own isn't happening. Common symptoms:
- System → Backups lists only backups you took by hand, even though the schedule is on
- A scheduled automation flow hasn't run since some point in the past
- Automation runs sit in
pendingorrunningforever - System → Monitor → Health shows Scheduled maintenance: Overdue
The API is otherwise fine — pages load, records save, nothing errors.
#Cause
All of these ride on one hourly cron trigger (see Scheduled tasks). When that tick stops, HTTP traffic is completely unaffected, so nothing looks wrong until you check something the tick was supposed to have done.
#Diagnosis
Start with the health endpoint — it answers the question directly:
curl -s https://your-instance.workers.dev/health | jq .cron
"stale": true— the tick is not arriving. Continue below."stalled": ["backups"]— the tick is arriving and this task is not in it. Skip to A single task has stopped running."failing": ["backups"]— the tick is arriving but one task fails every hour. Skip to A single task fails every hour."stale": null— no tick recorded and no deploy stamp to age it against. Expected on a localwrangler devbuild; on a deployed instance, redeploy so the build is stamped, then re-check.
Check stalled even when stale is false and failing is empty. A task that has stopped running keeps the ok: true from the last time it did run, so it appears in neither of the other two lists — it looks, field by field, exactly like a task that is working.
#Fix: confirm the trigger exists on the deployed Worker
A cron trigger is part of the Worker's deployed configuration, not its code. If [triggers] was missing from wrangler.toml at deploy time, Cloudflare has nothing scheduled and the code is irrelevant.
In the Cloudflare dashboard: Workers & Pages → your -api worker → Settings → Triggers → Cron Triggers. There should be one entry, 0 * * * *. If it is absent, confirm your workers/gateway/wrangler.toml contains:
[triggers]
crons = ["0 * * * *"]
then redeploy with npm run update.
#Fix: check the Worker logs for a handler error
If the trigger exists but nothing runs, the runtime is rejecting the event. Tail the logs and wait for the top of the hour:
npx wrangler tail --config workers/gateway/wrangler.toml
The error to look for is:
Handler does not export a scheduled() function
This means the Worker code and its cron trigger disagree. In the ES-module Worker format the runtime inspects only the default export for handlers — a top-level export async function scheduled() compiles, type-checks and passes review, but the runtime never sees it. The entrypoint must expose both handlers on the object it default-exports:
export default {
fetch: (request, env, ctx) => app.fetch(request, env, ctx),
scheduled
} satisfies ExportedHandler<Env>;
Note that satisfies ExportedHandler<Env> will not catch a missing handler on its own — every handler on that interface is optional.
Nothing in this failure mode touches HTTP, so an instance in this state serves traffic perfectly while every scheduled task silently does not run. If you are running a released build, upgrade rather than patching: see Upgrades.
#A single task has stopped running
When cron.stale is false and cron.stalled names a task, the tick is arriving and that one task is not part of it. The panel reports Task stopped and shows when it last ran.
This is the hardest of the three to read, because every other field says the instance is fine. A task that has stopped keeps the state from the last time it ran, so its ok is still true, its lastSuccess is still set, and only the age of its lastRun gives it away.
Two causes, and they want opposite responses:
- The task was renamed or retired in an upgrade. Its old name is dropped from the heartbeat by the first tick after the upgrade, so this clears itself within the hour. If it does not, the deployed Worker is older than you think — check
versionon/health. - The task is genuinely no longer being dispatched. Compare the names in
cron.tasksagainst the task list in Scheduled tasks. A name that is on that list and installedis a real fault; treat it as you would an overdue tick and check the Worker logs for the task's own error prefix ([cron:<task>]).
#A single task fails every hour
When cron.stale is false but cron.failing is not empty, the tick is healthy and one task is broken. System → Monitor → Health names it, shows the error from its last attempt, and shows when it last succeeded.
| Task | Usual cause |
|---|---|
backups |
The backups bucket is missing or the Cloudflare API credentials are unset — check System → Backups for the specific reason |
system-schedulers |
The FLOW_SCHEDULER Durable Object binding is absent; check missing.optional on /health |
stale-runs, finalize-migrations, legacy-credentials |
A database error — the message on the panel is the D1 error verbatim |
A failing task does not stop the others: each is isolated so one failure cannot sink the tick and trigger a platform retry that double-runs whatever already succeeded.
#Catching it next time
Cron failures produce no HTTP signal, so nothing in normal uptime monitoring notices them. Two things worth setting up:
- Cloudflare notifications — in the dashboard, add a notification for Workers cron trigger failures on your account. This is the only check that fires when the handler is unreachable and therefore cannot write its own heartbeat.
- Health polling — alert on
.cron.stale == true,.cron.stallednon-empty and.cron.failingnon-empty fromGET /health. All three, because they are three different failures: the tick stopping, one task dropping out of a tick that is otherwise on time, and a task that ran and threw. A rule written onstalealone stays quiet for the other two, and thestalledcase reports nothing anywhere else at all.