# Cloudflare Cron Triggers: How Scheduled Workers Run and How to Monitor

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/cloudflare-cron-triggers

> How Cloudflare Cron Triggers run a Worker: wrangler config, the scheduled() handler, cron syntax, CPU and wall-time limits, local testing, and failure alerts.

**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](https://developers.cloudflare.com/workers/configuration/cron-triggers/#via-the-wrangler-configuration-file)):

```toml
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](https://developers.cloudflare.com/workers/wrangler/configuration/#inheritable-keys), 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](https://developers.cloudflare.com/workers/runtime-apis/handlers/scheduled/)):

- `controller.cron` is the expression that fired. It "must match character-for-character, including spacing" when your code compares it.
- `controller.scheduledTime` is when the run was scheduled, in milliseconds since the Unix epoch.
- `controller.noRetry()` is covered under retries below.
- `env` holds bindings and secrets; `ctx` provides `waitUntil()`.

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](https://developers.cloudflare.com/workers/configuration/cron-triggers/#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](https://developers.cloudflare.com/workers/configuration/cron-triggers/#supported-cron-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](https://developers.cloudflare.com/workers/platform/limits/#account-plan-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](https://developers.cloudflare.com/workers/platform/limits/#wall-time-limits-by-invocation-type) 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](https://developers.cloudflare.com/workers/platform/changelog/#2023-10-18), though the [Durable Objects alarms](https://developers.cloudflare.com/durable-objects/api/alarms/) page still mentions it.
- **CPU time excludes waiting** on `fetch()`, KV, or database calls ([CPU time](https://developers.cloudflare.com/workers/platform/limits/#cpu-time)), so an I/O-bound job rarely reaches it. A Worker that keeps exceeding it is terminated, recorded as `exceededCpu`.

For longer work, [Workflows](https://developers.cloudflare.com/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](https://developers.cloudflare.com/workflows/build/trigger-workflows/)). With [Queues](https://developers.cloudflare.com/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](https://developers.cloudflare.com/workers/runtime-apis/context/#waituntil)). 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++](https://github.com/cloudflare/workerd/blob/v1.20261025.1/src/workerd/io/worker-entrypoint.c%2B%2B#L953), [io-context.c++](https://github.com/cloudflare/workerd/blob/v1.20261025.1/src/workerd/io/io-context.c%2B%2B#L827-L865)).

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](https://developers.cloudflare.com/workers/configuration/cron-triggers/#view-past-events)). [Workers Logs](https://developers.cloudflare.com/workers/observability/logs/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](https://developers.cloudflare.com/workers/runtime-apis/handlers/tail/#tailitems)). Neither sends notifications. [Workers Issues](https://developers.cloudflare.com/workers/observability/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](https://developers.cloudflare.com/workers/observability/issues/automations/) 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](https://github.com/cloudflare/workerd/blob/v1.20261025.1/src/workerd/io/worker-interface.capnp#L399-L403), [io-context.h](https://github.com/cloudflare/workerd/blob/v1.20261025.1/src/workerd/io/io-context.h#L1299-L1300)). What the platform does with that flag isn't documented. [Durable Object alarms](https://developers.cloudflare.com/durable-objects/api/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](https://cronradar.com/blog/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:

```bash
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](https://github.com/cloudflare/workers-sdk/blob/wrangler%404.151.3/packages/miniflare/src/workers/core/scheduled.ts), [Wrangler API](https://developers.cloudflare.com/workers/wrangler/api/)). Add `time` in epoch milliseconds to set `controller.scheduledTime` ([HTTP request](https://developers.cloudflare.com/workers/configuration/cron-triggers/#use-an-http-request)). Current Wrangler 4 serves this route with or without the flag; `--test-scheduled` adds the older `/__scheduled` route ([miniflare options](https://github.com/cloudflare/workers-sdk/blob/wrangler%404.151.3/packages/wrangler/src/dev/miniflare/index.ts#L1208), [middleware-scheduled.ts](https://github.com/cloudflare/workers-sdk/blob/wrangler%404.151.3/packages/wrangler/templates/middleware/middleware-scheduled.ts)). With Wrangler 4.139.0 or later, [Local Explorer](https://developers.cloudflare.com/workers/local-development/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](https://developers.cloudflare.com/workers/configuration/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`](https://developers.cloudflare.com/workers/versions-and-deployments/deployment-management/)                             |
| `crons` commented out                    | Wrangler leaves deployed triggers in place when `triggers` or `crons` is missing; `crons = []` removes them ([triggers](https://developers.cloudflare.com/workers/wrangler/configuration/#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](https://cronradar.com/blog/cron-dead-mans-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`):

```typescript
// 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 the `complete` or `fail`. Awaiting keeps `start` before 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 a `fetch()` that is neither awaited nor passed to `waitUntil()` 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](https://developers.cloudflare.com/changelog/post/2026-08-19-abort-signal-global-scope/)), 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 unmatched `controller.cron` throws for the same reason.
- **The deadline turns a hang into a `fail`** while time remains to report it. It only covers waiting: timers don't advance while code runs on the CPU ([timers](https://developers.cloudflare.com/workers/runtime-apis/performance/)), and a Worker terminated for CPU, memory, or wall time never sends its `fail`. [Cron job timeouts](https://cronradar.com/blog/cron-job-timeout) 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](https://docs.cronradar.com/schedules), 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](https://cronradar.com/blog/cron-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](https://cronradar.com/blog/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.
