◆ Autopilot Studio
DevNotes

Cloudflare Cron Triggers Not Firing? Use a Durable Object Alarm

2026-10-02 · 4 min read

a vintage brass alarm clock with tiny gears made of circuit traces, glowing cloud in the background, dark blue and amber palette, minimal

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

  1. 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.
  2. One door, one gap. The alarm, the cron and any other wake-up call tick(), which refuses to start the job twice within MIN_GAP_MS. This is what lets you keep several wake-up paths without ever doubling the work.
  3. 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.
  4. Make the clock visible. Store last_run, last_source and next_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.

#cloudflare workers#durable objects#cron triggers#alarms#scheduling

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.

0x13dd72Fa0E7504790585D92bD98c720f6fD2aBa6

More from DevNotes