Durable Object alarms are the cheapest way to run a per-object timer on Cloudflare: one alarm per object, no cron, no queue. They are also an easy way to build an infinite loop that bills you while nobody is using the app. A developer recently posted a roughly $10,000 Cloudflare bill on X after a Durable Object alarm kept rescheduling itself, and in May a developer reported a $36,000 month for an 81-user app after a self-requeuing consumer and unbatched Durable Object writes multiplied each other. Neither needed traffic. Both needed only code that runs again on its own.
This guide adds four guards to a self-rescheduling alarm and a test that proves the loop stops. It takes about 15 minutes.
Why alarms loop so easily
From Cloudflare's alarms reference:

- Each object has one alarm. Calling
setAlarm()when one is already scheduled overrides it. - Alarms are at-least-once. If
alarm()throws, it is retried with exponential backoff starting at 2 seconds, up to 6 retries. The handler getsretryCountandisRetry. - The usual periodic pattern is to call
setAlarm()again from insidealarm(). - After a period of inactivity the constructor runs before
alarm(), so the docs tell you to checkgetAlarm()before setting one in the constructor.
Put those together and the failure modes write themselves: an interval read from storage that comes back as 0 or undefined, a setAlarm(Date.now()) on an error path, a fetch() handler that re-arms the alarm on every request, or work that is not idempotent and runs twice on a retry.
The bill follows the pricing page. On Workers Paid, alarm invocations count as Durable Object requests ($0.15 per million after the first million), and each setAlarm() is billed as one row written on the SQLite backend ($1.00 per million after 50 million included). Whatever work the handler does, its reads and writes come on top. A loop multiplies every one of those lines.
The guarded alarm
This object runs doWork() on an interval and refuses to become a loop. The four guards are marked in the comments.

// src/index.ts
import { DurableObject } from "cloudflare:workers";
export interface Env {
POLLER: DurableObjectNamespace<Poller>;
ALARMS_DISABLED?: string; // "1" stops every alarm on its next run
}
const MIN_INTERVAL_MS = 60_000; // guard 1: never fire more often than this
const MAX_RUNS_PER_HOUR = 120; // guard 2: per-object run budget
const MAX_BACKOFF_MS = 3_600_000;
type Budget = { hour: number; runs: number };
export class Poller extends DurableObject<Env> {
async start(intervalMs: number): Promise<void> {
await this.ctx.storage.put("intervalMs", Math.max(intervalMs, MIN_INTERVAL_MS));
await this.ctx.storage.delete("tripped");
if ((await this.ctx.storage.getAlarm()) === null) {
await this.ctx.storage.setAlarm(Date.now() + MIN_INTERVAL_MS);
}
}
async alarm(): Promise<void> {
// guard 3: global kill switch, no reschedule
if (this.env.ALARMS_DISABLED === "1") {
console.warn("poller: ALARMS_DISABLED=1, not rescheduling");
return;
}
// guard 2: count runs per clock hour; trip and stop past the budget
const now = Date.now();
const hour = Math.floor(now / 3_600_000);
const stored = await this.ctx.storage.get<Budget>("budget");
const budget: Budget = stored && stored.hour === hour ? stored : { hour, runs: 0 };
budget.runs++;
await this.ctx.storage.put("budget", budget);
if (budget.runs > MAX_RUNS_PER_HOUR) {
console.error(`poller: ${budget.runs} runs this hour, tripping`);
await this.ctx.storage.put("tripped", now);
return;
}
// guard 1: the interval can never drop below the floor
const raw = await this.ctx.storage.get<number>("intervalMs");
const interval = Math.max(Number(raw) || MIN_INTERVAL_MS, MIN_INTERVAL_MS);
// guard 4: idempotent work, keyed by time slot, so a retry or a
// duplicate run inside the same slot does nothing
const slot = Math.floor(now / interval);
const failures = (await this.ctx.storage.get<number>("failures")) ?? 0;
let next = now + interval;
if ((await this.ctx.storage.get<number>("lastSlot")) !== slot) {
try {
await this.doWork(slot);
await this.ctx.storage.put("lastSlot", slot);
await this.ctx.storage.put("failures", 0);
} catch (err) {
// handle failure ourselves: back off instead of throwing into
// the platform retry, and keep the schedule alive
const backoff = Math.min(interval * 2 ** (failures + 1), MAX_BACKOFF_MS);
await this.ctx.storage.put("failures", failures + 1);
console.error(`poller: work failed (${failures + 1}x), next in ${backoff} ms`, err);
next = now + backoff;
}
}
await this.ctx.storage.setAlarm(next);
}
private async doWork(slot: number): Promise<void> {
// your periodic job: poll an API, flush a buffer, expire sessions
await this.ctx.storage.put("lastWorkSlot", slot);
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/start") {
const stub = env.POLLER.get(env.POLLER.idFromName("main"));
await stub.start(5 * 60_000);
return new Response("started\n");
}
return new Response("not found\n", { status: 404 });
},
};
What each guard covers:
- Minimum interval. A bad value in storage, a
0,NaNor a missing key, cannot schedule the next run sooner than 60 seconds. - Run budget. Each object counts its runs per clock hour. With a 5-minute interval it should run 12 times; 120 means something is wrong, so it records
trippedand stops rescheduling. You restart it withstart()after fixing the cause. - Kill switch. One variable stops every object in the namespace on its next run without touching their storage.
- Idempotent work. The job is keyed by its time slot, so an at-least-once retry or a duplicate fire inside the same slot is a no-op.
The handler catches its own errors and backs off, doubling the interval up to an hour, instead of throwing. That trades the platform's six fast retries for a schedule that slows down when the dependency is down and never stops silently. If you prefer the platform retries, rethrow, but then keep the work idempotent.
The Wrangler config. Note the kill switch lives in vars:
{
"name": "poller",
"main": "src/index.ts",
"compatibility_date": "2026-10-01",
"durable_objects": {
"bindings": [{ "name": "POLLER", "class_name": "Poller" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Poller"] }],
"vars": { "ALARMS_DISABLED": "0" }
}
Save it as wrangler.jsonc. To pull the switch in an incident, set the value to "1" and deploy:
npx wrangler deploy
Every object stops at its next alarm. Turning the switch back to "0" does not restart them, because none rescheduled; call /start again (or start() per object) once the cause is fixed. That is deliberate: a kill switch that quietly resumes the loop when flipped back is not a kill switch.



