To monitor Cloudflare Workers Cron Triggers, give each cron expression its own heartbeat URL and ping it from the scheduled() handler only after the job succeeds. If the job throws, runs out of CPU, gets canceled, or never fires because a deploy changed the triggers, no ping arrives and you get an alert. The trap is the ping itself: a fetch() that is neither awaited nor passed to ctx.waitUntil() is canceled when the handler returns, so a ping sent that way never leaves the Worker.

I'm Léo, I build Hyperping, and this guide covers how Cron Triggers fail without telling anyone, what Cloudflare shows you natively, and the setup I use with Hyperping healthchecks. I checked every limit against Cloudflare's docs on October 8, 2026, and ran every snippet with Wrangler 4.149.0 (workerd 1.20261006.1) on macOS, triggering the handler through wrangler dev and pointing the ping URLs at a local listener. I did not deploy anything to Cloudflare, so the production-only behaviors below come from the docs and Wrangler's source.

Key takeaways

  • Cron Triggers run in UTC only, and Cloudflare numbers weekdays 1 = Sunday to 7 = Saturday. Copy 0 9 * * 1 into a standard cron tool and it watches Monday while your Worker runs on Sunday.
  • Work that is neither awaited nor passed to ctx.waitUntil() is canceled when scheduled() returns. In my tests, an unawaited ping never reached the listener, and the invocation still reported ok.
  • A rejected promise in ctx.waitUntil() marks the run as failed in Cloudflare (exception), but nobody gets notified: the dashboard keeps the 100 most recent events and Workers Logs keeps 3 days on Free, 7 on Paid.
  • Limits are per invocation: 15 minutes of wall time, 10 ms of CPU on Free, and on Paid 30 seconds or 15 minutes of CPU depending on whether the schedule runs more often than hourly.
  • Monitor from outside: one Hyperping healthcheck per expression in cron mode, in UTC, pinged at the end of a job wrapped in ctx.waitUntil().

How Cloudflare Workers Cron Triggers fail silently

A Cron Trigger maps a cron expression to your Worker's scheduled(controller, env, ctx) handler. Cloudflare's Cron Triggers docs say they "execute on UTC time" and run "on underutilized machines". Each invocation gets an outcome (ok, exception, exceededCpu...), and that outcome is all Cloudflare records. None of the failures below sends you anything.

Unawaited work is canceled when the handler returns

The Context API docs are explicit: "An async call that is neither awaited nor passed to ctx.waitUntil() can be canceled when the invocation ends, dropping logs, leaving writes unfinished, or failing silently." The scheduled handler docs add that the runtime waits for the promise returned by scheduled(), up to the 15 minute limit.

I ran four variants against a local listener to see what that means for a ping:

Handler code What reached the listener Outcome
Whole job in an async function, not awaited, not in waitUntil Nothing, not even /start ok
Job awaited, then fetch(pingUrl) without await The job's request, no ping ok
Job awaited, then a ping after a 500 ms delay, not awaited The job's request, no ping ok
Same delayed ping, passed to ctx.waitUntil() The job's request and the ping ok

The first row is the worst case: the job itself never ran, and Cloudflare would still record a successful invocation. Await everything, or hand it to ctx.waitUntil().

Exceptions and CPU limits stay in Cloudflare's logs

A handler that throws, or a promise passed to ctx.waitUntil() that rejects, ends with the outcome exception. The docs say "the first ctx.waitUntil to fail will be observed and recorded as the status in the Cron Trigger Past Events table". I confirmed it locally: the test route returned {"outcome":"exception","noRetry":false}. That is a record, not an alert.

Resource limits end the run the same way. From the Workers limits page:

Limit Workers Free Workers Paid
CPU time per scheduled invocation 10 ms 30 seconds for schedules under 1 hour, 15 minutes for 1 hour or more
Wall time per scheduled invocation 15 minutes 15 minutes
Subrequests per invocation (fetch(), KV, R2, D1...) 50 10,000 by default
Cron Triggers per account 5 250

Waiting on fetch() or storage does not count as CPU time, so a job that mostly calls APIs fits in 10 ms of CPU. A job that parses a large export does not, and it ends with exceededCpu. A job still waiting on a slow API after 15 minutes is cut off by the wall time limit.

Workers Free also caps the account at 100,000 requests a day, reset at midnight UTC. Cloudflare's pricing examples count each cron invocation as a request, and I found no page that says what happens to scheduled invocations once a busy fetch() handler has used up the quota.

No documented retry

Durable Object alarms have a documented retry policy. Cron Triggers do not. The runtime exposes controller.noRetry() (workerd source) and the local test route reports a noRetry field, but no page says when or how many times a failed scheduled invocation is retried. Plan as if a failed run is gone until the next scheduled time.

controller.cron must match character for character

Every expression in triggers.crons calls the same scheduled() handler, and you dispatch on controller.cron. The docs warn that it "must match character-for-character, including spacing". Move the nightly job from "0 3 * * *" to "0 4 * * *" in wrangler.jsonc without updating the switch, and the handler matches no case, does nothing, and returns ok every night. The code later in this guide throws in the default branch so that mismatch shows up as an exception.

Weekday numbers start at 1 for Sunday

The supported expressions table lists weekdays as 1-7, and the docs note: "Days of the week go from 1 = Sunday to 7 = Saturday, which is different on some other cron systems (where 0 = Sunday and 6 = Saturday)." Cloudflare also accepts Quartz-style L, W and #, which standard 5-field cron does not have. The schedule itself works fine on Cloudflare. The problem appears when you copy it into anything that reads standard cron, a monitor included, and the day shifts by one.

There is no timezone setting either. A job you want at 03:00 Paris time runs at 0 1 * * * UTC in summer and has to become 0 2 * * * in winter, by hand.

Trigger changes do not land where you expect

Several deploy behaviors change your schedules without an error:

  • Adding, changing or deleting a trigger "may take several minutes (up to 15 minutes) to propagate".
  • wrangler deploy replaces all the Worker's triggers with the crons array in your config. A trigger someone added in the dashboard disappears on the next deploy. The docs say "any previous Cron Triggers are replaced with those specified in the triggers array", and Wrangler 4.149.0's deploy code sends a PUT to the Worker's schedules endpoint with the comment "PUT will override previous schedules on this script".
  • If triggers or crons is missing from the config, the deployed triggers are left in place, and only "crons": [] removes them. Deleting the block does not stop a job.
  • With gradual deployments, wrangler versions upload does not apply trigger changes. You need wrangler triggers deploy.
  • triggers is an inheritable key: wrangler deploy --env staging schedules the same crons on the staging Worker unless env.staging.triggers overrides them.

How to check Cron Triggers with Cloudflare's own tools

Cloudflare records past runs in the dashboard, in logs and in its analytics API:

  • In the dashboard, open Workers & Pages, select the Worker, go to Settings, and under Trigger Events select View events. It "stores the 100 most recent invocations", and it can take up to 30 minutes before events show up for a new or renamed Worker.
  • Workers Logs records each invocation, with cron runs labeled by their expression, when "observability": { "enabled": true } is set (the default for new Workers). Retention is 3 days on Workers Free and 7 days on Workers Paid.
  • Real-time logs stream from a deployed Worker with wrangler tail.
  • The GraphQL Analytics API is what the docs point to for Cron Events over an API.
# Live stream from the deployed Worker, one line per invocation
npx wrangler tail scheduled-jobs --format pretty

# Only failed invocations
npx wrangler tail scheduled-jobs --format pretty --status error

In pretty mode, Wrangler prints a scheduled invocation as the expression, the scheduled time in your machine's locale and timezone, and the outcome, for example "0 3 * * *" @ 10/8/2026, 3:00:00 AM - Exception Thrown (printing.ts). I read that format in Wrangler's source; I did not tail a deployed Worker.

Locally, wrangler dev never fires triggers on its own. Wrangler 4.149.0 says so at startup: "Scheduled Workers are not automatically triggered during local development." You call the handler through a route instead:

npx wrangler dev

# In another terminal: run the "0 3 * * *" branch and get the outcome as JSON
curl "http://localhost:8787/cdn-cgi/local/scheduled?cron=0+3+*+*+*&format=json"
# {"outcome":"ok","noRetry":false}

# Override controller.scheduledTime (milliseconds since the epoch, here 2026-10-08 03:00 UTC)
curl "http://localhost:8787/cdn-cgi/local/scheduled?cron=0+3+*+*+*&time=1791428400000"

The + signs encode the spaces of the expression. On Wrangler 4.149.0 this route answered without any flag; --test-scheduled only adds the older /__scheduled route, which replies Ran scheduled event without the outcome.

All of these describe invocations that happened. When a trigger was removed by a deploy, never propagated, or matches the wrong weekday, there is no event to list, no log line to filter, and no outcome to tail. That case needs a check that expects a ping and notices when it does not come.

How to monitor Cloudflare Workers Cron Triggers with Hyperping

A Hyperping healthcheck is a secret URL that expects a request on a schedule. When the request does not arrive by the expected time plus a grace period, it opens an incident and alerts you, then resolves on the next successful ping. Healthchecks are included on every plan, Free included.

1. Create one healthcheck per cron trigger

In Hyperping, open Healthchecks, click Create healthcheck, name it after the job, and pick Cron. Create one per expression in triggers.crons, since each one runs a different job. Set the timezone to UTC, because that is the only timezone Cron Triggers use.

Each healthcheck gives you a URL like https://hc.hyperping.io/tok_....

2. Translate the expression to standard cron

Hyperping reads standard 5-field cron, where Sunday is 0. Minutes, hours, days of the month and months mean the same thing on both sides. Weekdays do not:

Cloudflare expression Runs on Cloudflare (UTC) Standard cron for Hyperping
*/15 * * * * Every 15 minutes */15 * * * * (every 15 minutes)
0 * * * * Every hour 0 * * * * (every hour)
0 3 * * * Daily at 03:00 0 3 * * *
0 15 1 * * 15:00 on the 1st of the month 0 15 1 * *
0 17 * * 1 or 0 17 * * SUN Sunday 17:00 0 17 * * 0
0 9 * * 2 or 0 9 * * MON Monday 09:00 0 9 * * 1
10 7 * * 2-6 or 10 7 * * MON-FRI Weekdays 07:10 10 7 * * 1-5
0 12 * * 7 or 0 12 * * SAT Saturday 12:00 0 12 * * 6
0 0 * * 1,7 Saturday and Sunday 00:00 0 0 * * 0,6
0 18 * * 6L Last Friday of the month 0 18 * * 5L
59 23 LW * * Last weekday of the month No equivalent, use simple mode
0 9 * * 2#1 First Monday of the month 0 9 * * 1#1

The rule is to subtract one from every Cloudflare weekday number, including in the L and # forms. Writing MON-FRI in wrangler.jsonc avoids the ambiguity on the Cloudflare side, then type the numeric form into Hyperping. I checked the plain expressions in the right column, and their next runs, with the parser behind our cron expression generator. Hyperping's healthchecks parse cron with the cron-parser library, which also accepts 5L (last Friday of the month) and 1#1 (first Monday): I ran both through it and got October 30 and November 2, 2026 as the next runs.

W has no equivalent. For a last-weekday schedule, use simple mode with a period equal to the longest gap between runs: 35 days always catches a missing run, at the cost of noticing a skipped short cycle a week late.

3. Store the ping URL as a Worker secret

The URL is a credential: anyone who has it can mark your job as healthy. Store it as a secret, not in vars:

npx wrangler secret put HYPERPING_SYNC_URL
npx wrangler secret put HYPERPING_NIGHTLY_URL
# Paste https://hc.hyperping.io/tok_your_nightly_token when prompted

For wrangler dev, put them in .dev.vars (and keep that file out of git). Point them at separate healthchecks, or leave them out, so local test runs do not feed the production ones:

# .dev.vars
HYPERPING_SYNC_URL=https://hc.hyperping.io/tok_your_staging_sync_token
HYPERPING_NIGHTLY_URL=https://hc.hyperping.io/tok_your_staging_nightly_token

If you deploy a staging environment that inherits the triggers, give it its own secrets with npx wrangler secret put HYPERPING_NIGHTLY_URL --env staging.

4. Ping /start, run the job, ping on success

The configuration, with two triggers:

// wrangler.jsonc
{
  "name": "scheduled-jobs",
  "main": "src/index.js",
  "compatibility_date": "2026-10-01",
  "observability": { "enabled": true },
  "triggers": {
    "crons": ["*/15 * * * *", "0 3 * * *"]
  }
}

And the Worker. Each job is wrapped so the /start ping, the job and the success ping run in one promise handed to ctx.waitUntil():

// src/index.js
const PING_TIMEOUT_MS = 10_000;

// Never throws: a ping that fails is logged and the job carries on.
async function ping(url) {
  try {
    const res = await fetch(url, {
      headers: { "User-Agent": "scheduled-jobs-worker" },
      signal: AbortSignal.timeout(PING_TIMEOUT_MS),
    });
    await res.body?.cancel();
    if (!res.ok) console.error(`Hyperping ping returned ${res.status}`);
  } catch (err) {
    console.error(`Hyperping ping failed: ${err}`);
  }
}

// /start, then the job, then the success ping only if the job did not throw.
async function monitored(url, job) {
  if (!url) {
    console.warn("No Hyperping URL set, running the job unmonitored");
    return job();
  }
  await ping(`${url}/start`);
  try {
    await job();
  } catch (err) {
    console.error(`Job failed, no success ping: ${err}`);
    throw err; // so Cloudflare records the invocation as failed too
  }
  await ping(url);
}

async function syncInventory(env) {
  // Your job. Throw, or let errors propagate, when it fails.
}

async function nightlyCleanup(env) {
  // Your job.
}

export default {
  async scheduled(controller, env, ctx) {
    switch (controller.cron) {
      case "*/15 * * * *":
        ctx.waitUntil(monitored(env.HYPERPING_SYNC_URL, () => syncInventory(env)));
        break;
      case "0 3 * * *":
        ctx.waitUntil(monitored(env.HYPERPING_NIGHTLY_URL, () => nightlyCleanup(env)));
        break;
      default:
        throw new Error(`No job for cron "${controller.cron}"`);
    }
  },
};

/start opens a run in Hyperping without moving the deadline. The plain URL closes it, records the duration and sets the next deadline. When the job throws, the error is logged, rethrown so the invocation shows exception in Cloudflare, and the success ping is never sent: the healthcheck goes down once the grace period runs out. A ping that fails or times out is caught, so Hyperping being unreachable never fails your job.

I ran this exact handler with wrangler dev, with job bodies calling a local test API:

Scenario Listener received Outcome
Job succeeds GET .../start, then GET .../tok_..., user agent scheduled-jobs-worker ok
Job's API returns 503 and the job throws GET .../start only exception
Ping URL points at a closed port The job's request only, Hyperping ping failed: Error: Network connection lost. in the log ok
Unknown expression 0 3 * * SUN Nothing exception

Without the User-Agent header, the pings arrived with an empty user agent in my local tests, which is harder to spot in the healthcheck's ping history.

Each ping is a subrequest: two per run, out of 50 per invocation on Workers Free and 10,000 on Paid. Waiting for them does not use CPU time. Even a * * * * * trigger sends only two pings a minute, well under Hyperping's limit of 10 per minute per healthcheck.

5. Size the grace period to the job's run time

In cron mode, Hyperping expects the success ping by the scheduled time plus the grace period. The ping is sent when the job ends, so the grace period has to cover the run time, and the few seconds or minutes Cloudflare may take to start the invocation.

Cloudflare gives you an upper bound: a scheduled invocation cannot run longer than 15 minutes, so a 20 minute grace period covers any run that can still succeed. For short jobs, use the durations Hyperping records between /start and the success ping and set the grace period to about twice the longest one, with a few minutes of margin. A sync that takes 40 seconds every 15 minutes is fine with 5 minutes. The default is 10 minutes, and the minimum is 1.

6. Route the alerts

A missed ping goes to every channel connected to the project at once: email and SMS to every member, Slack, Discord, Telegram, PagerDuty and Opsgenie. Healthchecks do not use escalation policies or on-call schedules, so if a failed nightly job has to wake someone, send the alert to PagerDuty or Opsgenie and let their rotation decide who gets paged.

Test it before you trust it

Start locally: put a staging healthcheck URL in .dev.vars, run npx wrangler dev, and call curl "http://localhost:8787/cdn-cgi/local/scheduled?cron=0+3+*+*+*&format=json". You should get {"outcome":"ok","noRetry":false}, and the healthcheck's last pings should show a STARTED and an OK entry with scheduled-jobs-worker as the user agent.

Then test the real schedule on a staging Worker. Add a trigger a few minutes ahead in UTC, deploy, and create its healthcheck with the translated expression. Wait for the ping, keeping in mind the 15 minute propagation delay. Then make the job throw, deploy again, and wait: wrangler tail --format pretty shows Exception Thrown, no success ping arrives, and the alert should reach you once the grace period passes. Fix it, and the incident closes on the next successful run.

If your jobs run on other platforms too, the same pattern works for Vercel cron jobs, GitHub Actions scheduled workflows and Kubernetes CronJobs. For a side by side of heartbeat tools, see the best cron job monitoring tools. If the same jobs run in a Node.js process elsewhere, see Node.js cron jobs.