Cloudflare Cron Triggers Not Firing? Use a Durable Object Alarm

A scheduled job that does not run is worse than no scheduled job, because you only find out when the results stop appearing. The Cloudflare community has plenty of reports of Cron Triggers that are registered and visible in the dashboard but never fire, and during our first hours of testing we could not get a reliable signal from ours. The three-hour cron did eventually fire in production, but a clock you cannot predict is not a foundation for a site that must publish every three hours without anyone watching. So we moved the schedule to something we can observe: a Durable Object alarm, with the cron kept as a spare.
Why an alarm is a better clock
A Durable Object can call storage.setAlarm(timestamp). At that time the runtime calls the object's alarm() method, and if it throws, the runtime retries it. The alarm lives in the object's own storage, so you can read it back (getAlarm()), show it on a status page and notice immediately when it is missing. SQLite-backed Durable Objects are included in the Workers Free plan, and a clock that wakes up eight times a day uses almost nothing.
The code
wrangler.toml:
name = "clock-demo"
main = "src/index.js"
compatibility_date = "2026-09-01"
[[durable_objects.bindings]]
name = "CLOCK"
class_name = "Clock"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["Clock"]
[triggers] # optional backup path
crons = ["17 */3 * * *"]
src/index.js:
import { DurableObject } from "cloudflare:workers";
const num = (v, d) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : d);
export class Clock extends DurableObject {
get every() { return num(this.env.EVERY_MS, 3 * 3600 * 1000); } // how often the job should run
get gap() { return num(this.env.MIN_GAP_MS, 90 * 60 * 1000); } // never twice within this window
// Idempotent: makes sure exactly one alarm is scheduled.
async arm(inMs) {
const next = await this.ctx.storage.getAlarm();
if (next === null || inMs) await this.ctx.storage.setAlarm(Date.now() + (inMs ?? this.every));
return this.status();
}
// Every wake-up path (alarm, cron, visitor) goes through here.
async tick(source) {
const last = (await this.ctx.storage.get("last_run")) ?? 0;
if (Date.now() - last < this.gap) return { skipped: true, source };
await this.ctx.storage.put("last_run", Date.now());
const runs = ((await this.ctx.storage.get("runs")) ?? 0) + 1;
await this.ctx.storage.put("runs", runs);
await this.ctx.storage.put("last_source", source);
// ... start your real job here ...
return { started: true, source, runs };
}
// Re-arm FIRST, so the chain survives even if the job throws.
async alarm() {
await this.ctx.storage.setAlarm(Date.now() + this.every);
await this.tick("alarm");
}
async status() {
const next = await this.ctx.storage.getAlarm();
return {
runs: (await this.ctx.storage.get("runs")) ?? 0,
last_source: (await this.ctx.storage.get("last_source")) ?? null,
last_run: new Date((await this.ctx.storage.get("last_run")) ?? 0).toISOString(),
next_alarm: next ? new Date(next).toISOString() : null,
};
}
}
const clock = (env) => env.CLOCK.get(env.CLOCK.idFromName("main"));
export default {
async fetch(req, env) {
const url = new URL(req.url);
if (url.pathname === "/arm") return Response.json(await clock(env).arm(Number(url.searchParams.get("in")) || undefined));
return Response.json(await clock(env).status());
},
async scheduled(_event, env) {
await clock(env).tick("cron"); // optional backup path
},
};
Four design rules that make it dependable
- Re-arm before you work.
alarm()schedules the next alarm on its first line. If the job throws, the runtime retries the handler, but the chain of future alarms is already safe. - One door, one gap. The alarm, the cron and any other wake-up call
tick(), which refuses to start the job twice withinMIN_GAP_MS. This is what lets you keep several wake-up paths without ever doubling the work. - Arm from more than one place. Call
arm()from your deploy script, and also from a visitor request when the last run looks stale. Our production clock does that: a page view pokes the object at most every ten minutes per isolate, and if nothing started in four hours the next visitor starts the job. - Make the clock visible. Store
last_run,last_sourceandnext_alarm, and show them on a status page. In our system the dashboard shows which path woke the clock last (alarm, cron or visitor). That is how we know the alarm works.
Test it locally in 30 seconds
Alarms fire normally under wrangler dev, so shrink the intervals with variables:
npx wrangler dev --local --var EVERY_MS:4000 --var MIN_GAP_MS:1000
curl 'http://127.0.0.1:8787/arm?in=3000' # schedule the first alarm in 3 seconds
sleep 4; curl http://127.0.0.1:8787/ # {"runs":1,"last_source":"alarm", ...}
sleep 4; curl http://127.0.0.1:8787/ # {"runs":2,"last_source":"alarm", ...}
When we ran exactly this, the counter went 1, 2, 3, 4 at four-second steps and next_alarm always pointed into the future. To test the backup path, call curl http://127.0.0.1:8787/cdn-cgi/local/scheduled, because wrangler dev does not fire cron triggers on its own.
What it looks like in production
Our clock wakes at minute 12 of every third hour (UTC), and the cron trigger is set five minutes later as a spare. On 2 October 2026 the 09:12 alarm started the production cycle on its own, and the 09:17 cron call arrived, found a run less than 90 minutes old and skipped. That skipped call is the design working: two independent paths, one job.
If you want a complete worked example of what to start from the alarm, see How to Add x402 Payments to a Cloudflare Worker with Hono for the paid-API half of the same project.
FAQ
Are Durable Objects available on the Workers Free plan?
Yes, SQLite-backed Durable Objects are available on the free plan with usage limits. Declare the class with new_sqlite_classes in your migration, as in the wrangler.toml above.
What happens if the alarm handler throws?
The runtime retries a failed alarm automatically. That is why the handler schedules the next alarm first and then does the work: the chain of alarms cannot break.
Can a Durable Object have more than one alarm?
No. Each object has a single alarm, and setAlarm replaces the previous one. If you need several schedules, store them in storage and always set the alarm to the earliest one.
Does wrangler dev fire cron triggers?
Not automatically. Trigger one by hand with curl http://127.0.0.1:8787/cdn-cgi/local/scheduled. Alarms, in contrast, fire normally in wrangler dev.
Found this useful? Tip the studio in crypto
Every EVM chain works. USDC on Base is recommended: fees are a fraction of a cent. No account needed — it goes straight to the creator's wallet.
0x13dd72Fa0E7504790585D92bD98c720f6fD2aBa6More from DevNotes

How to Add x402 Payments to a Cloudflare Worker with Hono
A tested, minimal example of charging per request in USDC on Base with x402, Hono and Cloudflare Workers…

How an AI Agent Pays an x402 API: a 20-Line Client in JS
A working x402 client in JavaScript: sign the USDC payment, read the receipt, cap what your agent can spend…

How to List Your x402 API on 402 Index, x402scan and Bazaar
The exact steps to get a pay-per-call x402 API discovered by AI agents: OpenAPI metadata, 402 Index, x402scan…

Workers AI Free Tier: What Each API Call Really Costs in Neurons
Plan a pay-per-call AI API on Cloudflare's free 10,000 neurons a day: the cost of images, speech, embeddings…