Skip to main content
Every Stateful Actor has a single per-instance alarm — a one-shot timer you can set, replace, read, or clear from inside any method. When the alarm fires, the runtime calls the actor’s alarm(alarmInfo) handler. Alarms are the way to do deferred work without an external scheduler.
WhatHow
Set or replace the alarmawait this.ctx.storage.setAlarm(when)when is ms since epoch
Read the scheduled timeawait this.ctx.storage.getAlarm()number | null
Clear the alarmawait this.ctx.storage.deleteAlarm()
Top-level aliasawait this.ctx.setAlarm(when) (mirrors storage.setAlarm)
Handle the fireoverride async alarm(info: AlarmInfo) on your subclass
There is one alarm per actor instance, not per method or per key. Setting it again replaces the previous time.

Set and Handle

import { StatefulActor, type AlarmInfo } from "@telnyx/edge-runtime";

export class Session extends StatefulActor {
  // Called when a session is touched — push the timeout out by 5 minutes.
  async touch() {
    await this.ctx.storage.put("last_seen", Date.now());
    await this.ctx.storage.setAlarm(Date.now() + 5 * 60_000);
  }

  // Fires once when the alarm goes off.
  async alarm(info: AlarmInfo): Promise<void> {
    const lastSeen = (await this.ctx.storage.get<number>("last_seen")) ?? 0;
    if (Date.now() - lastSeen > 5 * 60_000) {
      // session actually idle — clean up
      await this.ctx.storage.deleteAll();
    }
  }
}
The handler is just another method call on the actor — it inherits the same single-threaded dispatch guarantee as any other method. You don’t need a lock around the state the alarm touches.

Delivery Contract

AlarmInfo:
interface AlarmInfo {
  retryCount: number;   // 0 on first delivery; increments on each retry
  isRetry: boolean;     // true when retryCount > 0
}
  • At-least-once delivery. Plan for the handler to run more than once for the same scheduled time. Make writes idempotent — re-check state in storage before applying effects (as the Session example above does), or record a done-marker keyed by the scheduled time.
  • A failed run is redelivered 3 times, about a second apart. If the handler throws (or exceeds its time budget), the platform re-fires it with retryCount incremented — 4 attempts total, then the alarm is deleted (getAlarm() returns null). There is no signal when that happens.
  • A failing handler loses its alarm — don’t use throw as your retry mechanism. Three retries a second apart won’t outlast a real outage. If the deferred work must happen, make the handler tolerate its own errors: catch, record, and re-arm with setAlarm at a backoff you choose.
  • Use info.isRetry to branch. On retry, log it, treat the work as best-effort, or skip already-completed steps.
async alarm(info: AlarmInfo): Promise<void> {
  if (info.isRetry) {
    // redelivery after a failed run — be extra careful about double-applies
  }
  // re-check state in storage before applying effects, then do the work
}

Patterns

TTL / expiry

Set the alarm when you create the row; on fire, check the row is still expired and delete it. If the row was touched since, reset the alarm instead.

Retry backoff

If your method kicks off a flaky external call, set an alarm to retry later; on fire, attempt again and either succeed or set a new alarm with a longer backoff.

Session timeout

See Session.touch above — every interaction pushes the alarm out by the idle window; the alarm only fires if no one touches the session in time.

Coalesced drain

Because there’s only one alarm per instance, you can use it as a “flush trigger”: set it on the first write, and when it fires, drain the queue and clear it. Subsequent writes during the window just append to the queue.

Limits

  • One alarm per actor instance. If you need multiple timers, keep a queue in storage and use the single alarm to drive a scheduler loop.
  • when is ms since epoch. Don’t pass seconds. Firing precision is about one second — don’t schedule sub-second work with it.
  • No recurring flag. If you want periodic work, set the next alarm at the end of your alarm handler.
  • The handler has a time budget. alarm() runs under its own wall-clock cap — larger than the 30-second method budget, on the order of minutes, but still bounded. A run that exceeds it counts as a failure and is redelivered.
  • deleteAll() does not clear the alarm. Keys and the alarm are separate; a pending alarm still fires after deleteAll(), and a handler that re-arms itself will keep running against empty state. To decommission an actor: deleteAlarm() first, then deleteAll().

Next Steps