The answer. A Cron Trigger is one schedule for the whole Worker. When the schedule belongs to a thing (expire this session in 30 minutes, retry this tenant's sync at 09:00 in its timezone, flush this buffer 5 s after the last write), that thing should be a Durable Object with an alarm. this.ctx.storage.setAlarm(timestamp) stores one pending wake-up per object (setting it again replaces it); when it fires, the runtime calls alarm() on that object with its storage intact, even if the object had been evicted. If alarm() throws, it is retried with backoff; if it returns, the alarm is consumed, so recurring work ends by calling setAlarm again. Debouncing falls out for free: every write moves the alarm forward, and the flush runs once, after the last one.
The pattern.
export class Buffer extends DurableObject<Env> {
async append(event: unknown) {
this.ctx.storage.sql.exec("insert into pending (body) values (?)", JSON.stringify(event));
await this.ctx.storage.setAlarm(Date.now() + 5_000); // slide the flush 5 s after the latest write
}
async alarm() {
const rows = this.ctx.storage.sql.exec("select id, body from pending").toArray();
if (rows.length === 0) return;
await this.env.SINK.sendBatch(rows.map((r) => ({ body: JSON.parse(r.body as string) })));
this.ctx.storage.sql.exec("delete from pending where id <= ?", rows.at(-1)!.id);
}
}
Watch out.
- An alarm handler has 15 minutes of wall time and runs concurrently with other requests to the object; do not assume it holds a lock.
deleteAll()now deletes the pending alarm too (compatibility date 2026-02-24 or later). Older Workers need a separatedeleteAlarm().- Each
setAlarmis billed as a row written. A sliding alarm on a hot object is cheap; asetAlarmper event on millions of objects is a line item.
Related: durable-object-one-instance-per-id-with-sqlite · cron-triggers-scheduled-handler-basics · queues-batches-acks-and-retries