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.

An alarm lets a Durable Object wake itself up at a chosen time, with no incoming request involved. You set a timestamp. When it arrives, the runtime calls your alarm() handler. This is how a room expires itself, a subscription renews itself, or a game ends on schedule.

One constraint shapes everything: a Durable Object holds one alarm time. Setting another alarm replaces it. If your object has three future tasks and you call setAlarm for each, only the last survives.

So keep 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 don’t repeat on their own. Scheduling the next one is your job. Forgetting the re-arm at the end of the handler is the classic way a “recurring” job silently runs once.

The handler will run more than once

Alarm execution is at-least-once. If the handler throws, the runtime retries it with exponential backoff. In rare cases an alarm fires twice.

So make the handler safe to repeat. The done = 0 check above does it: a second run finds nothing due and exits cleanly, with the same final state. If you want to log retries, the handler receives alarmInfo with isRetry and retryCount.

One cost warning. Don’t wake every object on a short fixed interval. Ten thousand rooms each firing an alarm every ten seconds is real money and mostly useless work. Set an alarm when there’s a concrete task due, not as a polling loop.

Try this to check the idempotency claim: schedule one room-expiration task, trigger it in a test, and run the handler a second time. You should see the same rows, the same done flags, and no duplicate side effects.

Lesson completed