Timers & reminders
Two mechanisms, and the difference is the whole point: a timer dies with the activation, a reminder survives a restart and brings the actor back.
Timers — volatile
ctx.timer('poll', async () => {
ctx.state.checkedAt = new Date();
await ctx.save();
}, { due: 1_000, period: 5_000 });
Ticks run as ordinary turns, so they serialize with your methods and cannot race your state. Under load they coalesce rather than pile up.
A timer does not keep the actor alive. If the actor goes idle it deactivates, and the
timer goes with it. That is deliberate: a volatile timer is a convenience, not a reason to pin
an object in memory. Pass keepAlive: true when you really do want the actor held open.
Use a timer for something that only matters while the actor is already active — refreshing a cached value, expiring an in-memory lease, driving an animation of state you are streaming.
Reminders — durable
await ctx.reminders.set('daily-digest', { due: 60_000, period: 86_400_000 });
defineActor({
type: 'Digest',
async onReminder(ctx, name) {
if (name === 'daily-digest') await sendDigest(ctx.state);
},
// …
});
Reminders are stored through ActorStorage, fired by the host's
scheduler, and they re-activate an idle or restarted actor. That is the capability a
stateless function has nowhere to put.
The contract, stated precisely:
- Minimum period 60 seconds. Reminders are not a scheduler for sub-minute work; use a timer or a task.
- Coarse resolution — "at or after
nextDue", checked everyreminderTickMs(default 30s). Not a cron guarantee. - At-most-once per tick. A reminder is a wake-up, not a queue. If the work must not be lost, make it idempotent or record progress in state.
Choosing
| Want | Use |
|---|---|
| A tick while the actor is busy anyway | ctx.timer |
| To wake up in an hour, even after a deploy | ctx.reminders |
| Sub-second precision | neither — drive it from the caller |
| Something that must run exactly once | a job |
Pluggable backends
ActorReminders is a seam. The default rides your storage through shardedReminders(), and
providers replace it:
pgReminders()— a due-time-indexed table claimed withSKIP LOCKED, at-most-once, no catch-up bursts.durableObjectReminders()— the Durable Object alarm. Semantics differ visibly here: an alarm fires at the due time, whereshardedReminders()promises only "at or after".
The clock is a seam
Background work — the idle sweeper, the reminder tick, ctx.timer, write-behind flushes — all
runs on an ActorScheduler rather than on bare setTimeout. Set it on the app:
import { manualScheduler } from '@sigx/actors/host';
const scheduler = manualScheduler();
const app = defineActorApp({ actors, scheduler, defaults: { idleAfterMs: 0 } });
timerScheduler() is the default and uses host timers. manualScheduler() is driven by hand,
which is how you test idle deactivation, reminders and write-behind without sleeping:
scheduler.advance(60_000); // an hour of sweeps, instantly
advance(ms) fires due jobs in order and cannot go backwards.
Why it is a seam rather than a test shim. Background work is exactly the set of jobs that must keep running between requests — and a Cloudflare Worker only runs while it is handling one, so an interval registered at startup never fires there. A platform that schedules differently supplies its own scheduler instead of losing the capability.
Two things deliberately do not go through it: call deadlines and the shutdown drain. Both
are scoped to an in-flight request or to stop(), so they stay on host timers.
Cron-shaped work
There is no cron API, deliberately. The pattern is an actor per schedule that re-arms a
one-shot reminder from inside onReminder — written up in docs/job-recipes.md in the actors
repo, along with a singleton queue-worker and the Cloudflare caveats.
