Cloudflare Cron Triggers: How Scheduled Workers Run and How to Monitor
Contents
Cloudflare Workers Cron Triggers run a Worker's scheduled() handler on the five-field cron expressions listed under triggers.crons in its Wrangler configuration file, always in UTC. Every trigger calls the same handler, which reads controller.cron to decide which job to run. On Workers Paid, a cron invocation gets 15 minutes of wall time and 30 seconds of CPU time, or 15 minutes of CPU time when its schedule runs an hour or more apart; Workers Free allows 10 ms of CPU. A failed run is recorded in the Worker's Cron Events, and Workers Issues can notify you about it, but a trigger that stops firing records nothing at all. Cloudflare's behavior below was checked against its documentation and Wrangler 4.152.0 on October 25, 2026.
How Cron Triggers Invoke a Worker#
Schedules live in the Wrangler configuration file. This wrangler.toml defines two; in wrangler.jsonc the same list goes in "triggers": { "crons": [...] } (Cron Triggers):
name = "scheduled-jobs"
main = "src/index.ts"
compatibility_date = "2026-10-01"
[triggers]
# Always UTC. Each string reaches scheduled() unchanged, as controller.cron.
crons = ["*/10 * * * *", "0 7 * * MON-FRI"]npx wrangler deploy replaces the Worker's Cron Triggers with this list, including any added in the dashboard. triggers is an inheritable key, so a Wrangler environment such as staging runs these schedules too unless it sets its own [env.staging.triggers].
The scheduled() Handler#
In ES modules syntax the handler is scheduled(controller, env, ctx) (scheduled handler):
controller.cronis the expression that fired. It "must match character-for-character, including spacing" when your code compares it.controller.scheduledTimeis when the run was scheduled, in milliseconds since the Unix epoch.controller.noRetry()is covered under retries below.envholds bindings and secrets;ctxprovideswaitUntil().
Cloudflare runs cron invocations "on underutilized machines" to use spare capacity. With Green Compute, an account-level setting, they run only in data centers powered by renewable energy (Green Compute). The docs don't say how soon after the scheduled minute a run starts.
Cron Syntax: Five Fields, UTC, and Sunday Is 1#
Cloudflare accepts five fields plus "most Quartz scheduler-like cron syntax extensions" (supported expressions):
| Field | Values | Characters |
|---|---|---|
| Minute | 0-59 | * , - / |
| Hour | 0-23 | * , - / |
| Day of month | 1-31 | * , - / L W |
| Month | 1-12 or JAN-DEC | * , - / |
| Day of week | 1-7 (1 is Sunday) or SUN-SAT | * , - / L # |
Day-of-week numbering is the trap: a crontab's 1-5 pasted in runs Sunday through Thursday. Use names such as MON-FRI, which mean the same everywhere. The extensions cover dates five plain fields can't: 59 23 LW * * is 23:59 on the last weekday of the month and 0 18 * * 6L is the last Friday. There's no seconds field, so the most frequent schedule is once a minute.
Limits for Scheduled Workers#
Checked October 25, 2026, from Workers limits:
| Limit | Workers Free | Workers Paid |
|---|---|---|
| Cron Triggers per account | 5 | 250 |
| CPU time per cron invocation | 10 ms | 30 seconds under a 1-hour interval; 15 minutes at 1 hour or more |
| Wall time per cron invocation | 15 minutes | 15 minutes |
| Subrequests per invocation | 50 | 10,000 |
| Memory per isolate | 128 MB | 128 MB |
- The trigger count is per account. The limit of three per Worker was removed on October 18, 2023, though the Durable Objects alarms page still mentions it.
- CPU time excludes waiting on
fetch(), KV, or database calls (CPU time), so an I/O-bound job rarely reaches it. A Worker that keeps exceeding it is terminated, recorded asexceededCpu.
For longer work, Workflows run durable steps that retry automatically, and a Workflow binding's schedules array starts instances on a cron schedule without a scheduled() handler (trigger Workflows). With Queues, the cron handler enqueues work that consumer Workers process with retries and dead-letter queues, each consumer invocation with its own 15-minute limit.
When a Scheduled Run Ends: Await or ctx.waitUntil()#
In a fetch handler, ctx.waitUntil() keeps work alive "for up to 30 seconds after the response is sent" (context). A scheduled invocation has no response: the runtime waits for the promise scheduled() returns and every promise passed to ctx.waitUntil(), up to the 15-minute limit. In workerd, the open-source Workers runtime, "Scheduled handlers run entirely in waitUntil() tasks", and the invocation ends when those tasks finish or the wall-time limit fires (worker-entrypoint.c++, io-context.c++).
So await sequential work; waitUntil() adds nothing there. It helps when one invocation starts independent tasks concurrently. A promise that is neither awaited nor passed to waitUntil() "can be canceled when the invocation ends".
The recorded status follows the same promises: "The first ctx.waitUntil to fail will be observed and recorded as the status" in Cron Events. A handler that catches an error and returns normally is recorded as a success.
Failures, Retries, and Cron Events#
Cron Events (the Worker's Settings > Trigger Events > View events) keeps the 100 most recent invocations (past events). Workers Logs keeps cron invocation logs for 3 days on Free and 7 on Paid. Each invocation has an outcome: ok, exception for an uncaught error, exceededCpu, exceededMemory, canceled, and a few others (outcomes). Neither sends notifications. Workers Issues, once enabled with observability.issues.enabled (Wrangler 4.134.0 or later) or in the dashboard, groups uncaught exceptions and failed invocations into issues, and an automation can send an issue to a webhook, chat channel, or incident tool when it crosses an occurrence threshold.
Cloudflare's Cron Triggers documentation describes no retry policy. A handler can call controller.noRetry(), local tests report whether it did, and workerd returns a retry flag with every scheduled result, true unless noRetry() was called (worker-interface.capnp, io-context.h). What the platform does with that flag isn't documented. Durable Object alarms, by contrast, are documented as at-least-once with up to six retries. Don't count on a retry to recover a failed run, and make the job safe to run twice for the same scheduledTime in case one happens (idempotent scheduled jobs).
Test a Scheduled Worker Locally#
wrangler dev never fires Cron Triggers on its own. Start the dev server and call its scheduled route:
npx wrangler dev --test-scheduled
# In a second terminal. cron must match a trigger exactly; + encodes a space.
curl "http://localhost:8787/cdn-cgi/local/scheduled?cron=0+7+*+*+MON-FRI&format=json"The route answers {"outcome":"ok","noRetry":false} with status 200, or another outcome such as exception with status 500 (scheduled.ts, Wrangler API). Add time in epoch milliseconds to set controller.scheduledTime (HTTP request). Current Wrangler 4 serves this route with or without the flag; --test-scheduled adds the older /__scheduled route (miniflare options, middleware-scheduled.ts). With Wrangler 4.139.0 or later, Local Explorer triggers configured or ad hoc expressions from the browser. Local secrets go in .dev.vars or .env beside the configuration file, kept out of git (secrets).
Why a Cron Trigger Didn't Fire#
| Cause | What to check |
|---|---|
| The change is still propagating | Adding, changing, or deleting a trigger "may take several minutes (up to 15 minutes)" |
Uploaded with wrangler versions upload |
Version uploads don't apply trigger changes; run wrangler triggers deploy |
crons commented out |
Wrangler leaves deployed triggers in place when triggers or crons is missing; crons = [] removes them (triggers) |
| Crontab day numbers | 1 is Sunday and 2 is Monday |
controller.cron matches no branch |
The trigger fired and the handler did nothing; a switch whose default throws shows it in Cron Events |
| Account limit | 5 triggers across all Workers on Free |
| New or renamed Worker | Past Cron Events can take up to 30 minutes to appear |
Monitoring Cron Triggers With CronRadar#
Cron Events, Workers Logs, and Issues all start from an invocation. None of them can show the run that never happened: a trigger dropped by a deploy, a cron string the handler no longer matches, a deleted Worker. That takes a dead man's switch: each run reports to a monitor outside Cloudflare that knows the schedule, and a missing report is the alert.
There is no CronRadar SDK for Cloudflare Workers and no auto-discovery, so each job reports with its own HTTP check-ins. This src/index.ts matches the wrangler.toml above (types from wrangler types):
// src/index.ts
interface Env {
CRONRADAR_API_KEY: string // npx wrangler secret put CRONRADAR_API_KEY
}
type Job = (signal: AbortSignal) => Promise<void>
interface MonitorOptions {
runId: string
gracePeriod: number // seconds; applied only when a check-in creates the monitor
timeoutMs: number // the job's own deadline, inside the 15-minute wall-time limit
}
const PING_URL = 'https://cron.life/api/v1/ping'
// Monitoring must never break a job: 5-second timeout, errors logged, never thrown.
async function checkIn(
env: Env,
key: string,
event: 'start' | 'complete' | 'fail',
params: Record<string, string>,
): Promise<void> {
if (!env.CRONRADAR_API_KEY) {
console.error('CRONRADAR_API_KEY is not set; skipping check-in')
return
}
try {
const query = new URLSearchParams(params)
const response = await fetch(`${PING_URL}/${encodeURIComponent(key)}/${event}?${query}`, {
method: 'POST',
headers: { Authorization: `Basic ${btoa(`${env.CRONRADAR_API_KEY}:`)}` },
signal: AbortSignal.timeout(5000),
})
if (!response.ok) console.error(`CronRadar ${event} for ${key}: HTTP ${response.status}`)
await response.body?.cancel()
} catch (error) {
console.error(`CronRadar ${event} for ${key} failed: ${String(error)}`)
}
}
// Only the error's type leaves the Worker: messages can carry data.
function errorName(error: unknown): string {
if (typeof error === 'object' && error !== null && 'name' in error) return String(error.name)
return typeof error
}
// Rejects at the deadline, even if the job ignores the signal.
function expired(signal: AbortSignal): Promise<never> {
return new Promise((_, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
})
}
async function monitored(
key: string,
schedule: string,
fn: Job,
env: Env,
options: MonitorOptions,
): Promise<void> {
const params = { schedule, gracePeriod: String(options.gracePeriod), run_id: options.runId }
await checkIn(env, key, 'start', params)
const deadline = AbortSignal.timeout(options.timeoutMs)
try {
await Promise.race([fn(deadline), expired(deadline)])
} catch (error) {
const message = deadline.aborted
? `${key} stopped at its ${options.timeoutMs / 1000}-second deadline`
: `${key} threw ${errorName(error)}`
await checkIn(env, key, 'fail', { ...params, message })
throw error // Cron Events and Workers Logs still record the failure
}
await checkIn(env, key, 'complete', params)
}
async function syncInventory(signal: AbortSignal): Promise<void> {
const response = await fetch('https://inventory.example.com/api/sync', { method: 'POST', signal })
if (!response.ok) throw new Error(`Inventory sync returned HTTP ${response.status}`)
}
async function sendDailyDigest(signal: AbortSignal): Promise<void> {
const response = await fetch('https://mail.example.com/api/digests/daily', {
method: 'POST',
signal,
})
if (!response.ok) throw new Error(`Daily digest returned HTTP ${response.status}`)
}
export default {
async scheduled(controller: ScheduledController, env: Env): Promise<void> {
// scheduledTime identifies this occurrence, so its start and finish share a run_id.
const runId = String(controller.scheduledTime)
// Each case must match its string in wrangler.toml character for character.
switch (controller.cron) {
case '*/10 * * * *':
return monitored('inventory-sync', '*/10 * * * *', syncInventory, env, {
runId,
gracePeriod: 120,
timeoutMs: 60_000,
})
case '0 7 * * MON-FRI':
return monitored('daily-digest', '0 7 * * MON-FRI', sendDailyDigest, env, {
runId,
gracePeriod: 600,
timeoutMs: 10 * 60_000,
})
default:
throw new Error(`No job is mapped to cron "${controller.cron}"`)
}
},
} satisfies ExportedHandler<Env>- Check-ins are awaited, not handed to
ctx.waitUntil(). The runtime would wait for either, so neither loses thecompleteorfail. Awaiting keepsstartbefore the job and the finish after it, and costs at most 5 seconds of wall time per check-in and almost no CPU time. Only afetch()that is neither awaited nor passed towaitUntil()can be lost. - Monitoring can't break the job. Each check-in gives up after 5 seconds and logs instead of throwing.
AbortSignal.timeout()is supported but "still requires an active request context" (changelog), so it's created inside the handler. Two check-ins per run count against Free's 50 subrequests. - The job's error is rethrown after
fail, so Cron Events, Workers Logs, and Issues still see the failure. An unmatchedcontroller.cronthrows for the same reason. - The deadline turns a hang into a
failwhile time remains to report it. It only covers waiting: timers don't advance while code runs on the CPU (timers), and a Worker terminated for CPU, memory, or wall time never sends itsfail. Cron job timeouts covers finding what a hung job waits on. - The key is a Worker secret. Use a separate CronRadar application and key per environment, including a staging environment that inherits these triggers.
What CronRadar Sees From a Terminated Run#
A terminated run leaves a start with no finish. CronRadar flags that as stuck only when the job then goes silent: every check-in, including start, moves the expected time to the job's next scheduled occurrence, and the monitor goes critical as stuck if that time plus the grace period passes while the last event is still an unanswered start. The next run's start resets that window, so a job terminated on every run looks like a series of starts and isn't flagged. There's no per-job maximum-runtime setting. Keep CPU-heavy work in small batches or move it to Workflows or Queues, and watch the Worker's Metrics > Errors for Exceeded CPU Time Limits.
One Monitor per Cron Trigger#
Give each job its own key and send its schedule on every check-in. The first check-in creates the monitor, and if it's deleted, the next one recreates it. schedule and gracePeriod apply only when a check-in creates the monitor, so after changing a trigger in wrangler.toml, change the monitor's schedule in the CronRadar dashboard too.
CronRadar reads five-field cron in UTC, like Cloudflare, but Sunday is 0 and L, W, and # aren't accepted:
| Cloudflare trigger | CronRadar schedule |
Why |
|---|---|---|
0 7 * * MON-FRI |
0 7 * * MON-FRI |
Names mean the same to both |
0 7 * * 2-6 |
0 7 * * 1-5 |
Cloudflare counts Sunday as 1 |
0 17 * * 1 |
0 17 * * 0 |
Sunday |
59 23 LW * * |
59 23 26-31 * * |
Only with the change shown below |
For L, W, or #, change the trigger to every candidate date (59 23 26-31 * * covers every month's last weekday), let the job decide whether to work, and check in on every invocation, including early exits. When both day fields are restricted, CronRadar expects a run only on dates that match both, so restrict one.
CronRadar checks for overdue monitors once a minute. A monitor is warning once its expected time passes and becomes critical, with an incident, when it is both past the grace period and at least two minutes late. A fail alerts immediately. Set gracePeriod (seconds, minimum 60, default 90) to cover the delay before Cloudflare starts a run, which the docs don't bound; because start is reported first, the run's own length doesn't count against it; choosing a grace period works through the numbers. Alerts go to email, Slack, webhook, Discord, or PagerDuty.
A monitor created by its first check-in can't flag a trigger that never fires. For a new trigger, create the monitor in the CronRadar dashboard before deploying: it stays pending and sends a never-executed alert if the first expected run plus the grace period passes with no report. Then run the job locally against a development application, deploy, and confirm the first scheduled run reaches production's monitor. Vercel cron jobs uses the same pattern from a route handler.
Frequently Asked Questions#
What time zone do Cloudflare cron triggers use?#
UTC, with no time zone setting. Convert local times yourself and remember that the UTC hour moves with daylight saving time: 09:00 in New York is 13:00 UTC in summer and 14:00 UTC in winter.
How many cron triggers can a Worker have?#
There's no per-Worker limit since October 2023. The limit is per account: 5 Cron Triggers on Workers Free and 250 on Workers Paid, checked October 25, 2026. Workflow schedules allow up to 100 cron expressions per account.
Do Cloudflare cron triggers retry on failure?#
Cloudflare doesn't document a retry policy for Cron Triggers. The runtime has controller.noRetry() and reports whether a failed run may be retried, but what the platform does with that isn't stated. Treat a failed run as final when deciding what to alert on, and make the job idempotent in case it runs again.
How do I test a scheduled Worker locally?#
Run npx wrangler dev --test-scheduled, then request /cdn-cgi/local/scheduled with cron set to the trigger's exact expression, spaces encoded as +. Add format=json to see the outcome and time to set controller.scheduledTime, or use Local Explorer in the browser.
Why didn't my cron trigger fire after I changed it?#
Allow up to 15 minutes for propagation. Then confirm the change was applied by wrangler deploy or wrangler triggers deploy rather than only wrangler versions upload, that your handler still matches the new expression character for character, and that its day-of-week numbers follow Cloudflare's, where 1 is Sunday.
Put this guide to work
Connect your Node.js job to monitoring
Use the working HTTP reporting path in the guide while the SDK is unavailable. This requires explicit instrumentation and does not discover jobs automatically.
The Node.js SDK is temporarily unavailable: the published package cannot load because it is missing a required file. Use manual HTTP reporting below. This setup does not discover jobs automatically.