Alarms and WebSockets
Schedule per-object work with alarms
Use the single alarm per object for the next due task and make the handler idempotent and self-rescheduling.
8 minute lesson
An alarm lets a Durable Object wake itself up at a chosen time, with no incoming request involved. You set a timestamp, the runtime calls your alarm() handler when it arrives. This is how a room expires itself, a subscription renews itself, or a game ends on schedule.
The constraint that shapes everything: a Durable Object can schedule one alarm time. Setting another alarm replaces the existing one. If your object has three future tasks and you naively setAlarm for each, only the last survives.
So store a task table and schedule the earliest due item:
async scheduleTask(kind, dueAt) {
this.ctx.storage.sql.exec(
'insert into tasks (kind, due_at, done) values (?, ?, 0)', kind, dueAt)
const next = this.ctx.storage.sql
.exec('select min(due_at) as t from tasks where done = 0').one().t
await this.ctx.storage.setAlarm(next)
}
The alarm handler processes what’s due, marks it done, and re-arms for whatever remains:
async alarm() {
const now = Date.now()
const due = this.ctx.storage.sql
.exec('select id, kind from tasks where done = 0 and due_at <= ?', now)
.toArray()
for (const task of due) {
await this.runTask(task)
this.ctx.storage.sql.exec('update tasks set done = 1 where id = ?', task.id)
}
const next = this.ctx.storage.sql
.exec('select min(due_at) as t from tasks where done = 0').one().t
if (next) await this.ctx.storage.setAlarm(next)
}
Alarms do not repeat on their own — scheduling the next task only when work remains is your job, and forgetting the re-arm at the end is the classic way a “recurring” job silently runs once.
The handler will run more than once
Alarm execution is at-least-once. The alarm handler can retry after failure — an uncaught exception triggers automatic retries with exponential backoff — and in rare cases an alarm fires twice. Make it safe to repeat: the done = 0 check above means a second run finds nothing due and exits cleanly, producing the same final state. The handler even receives alarmInfo.isRetry and retryCount if you want to log retries.
One cost warning: avoid waking every object at a short fixed interval. Ten thousand rooms each firing an alarm every ten seconds is real money and mostly useless work. Set alarms when there is a concrete task due, not as a polling loop.
Verify the idempotency claim directly. Schedule one room-expiration task, trigger it in a test, and confirm a second run produces the same final state — same rows, same done flags, no duplicate side effects.
Lesson completed