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.
A tick carries the host's callTimeoutMs as its deadline, exactly like an external call, so
every ctx.actor() call it makes — same-host or cross-host — is bounded by it. A tick that
awaits a peer whose turn never answers rejects with ActorCallTimeoutError at the deadline
(30 seconds by default). If a long await inside a tick is intended, pass that one call a
budget with .with({ deadlineMs })
or raise callTimeoutMs.
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, at-least-once over time. A reminder is a wake-up, not a queue. The provider advances or removes a due entry before it dispatches, so a crash between the two skips one firing rather than doubling it.
- A failed dispatch is retried one tick later. A dispatch that rejects — a call deadline, a
host mid-restart, an
onReminderthat threw — re-arms the entry one tick out (now + reminderTickMs): a one-shot is re-inserted, a periodic one pulled forward. A target that never answers costs one attempt per tick, never a hot loop. - The actor's later decision wins. A reminder the actor set again while its dispatch was failing keeps the value the actor gave it, and a periodic one it cleared stays cleared.
Make onReminder idempotent. Two cases deliver more than once by design: a one-shot the
actor cleared while its dispatch was failing may still be delivered once more, and a dispatch
that timed out after onReminder had started is retried. If the work must not be repeated,
record progress in state and check it first.
Every failed attempt a provider reports (the built-in ones all do — see
writing your own) is counted in HostStats.remindersUndelivered — in host.stats(), the
ops endpoint snapshot, the metrics()
gauges and each host's entry in cluster stats. It is monotonic
for the host's lifetime; a value that keeps climbing is a target that never comes back.
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, passed as reminders: to defineActorApp or createHost. The
default is shardedReminders(), and the providers replace it with an index in the store you
already run:
| Provider | Where the reminders live | Who ticks |
|---|---|---|
shardedReminders() (default) | 16 records in your ActorStorage | each host ticks the shards it owns |
pgReminders() | one due-time-indexed table | every host claims, with SKIP LOCKED |
redisReminders() | one sorted set scored by due time | every host claims, one Lua script per batch |
surrealReminders() | one due-time-indexed table with a shard column | each host ticks the shards it owns |
durableObjectReminders() | the Durable Object's own storage | the DO alarm |
Every built-in provider keeps the contract above — at-most-once per firing, the one-tick retry
of a failed dispatch, the same "later decision wins" rules — and feeds
remindersUndelivered. Semantics differ visibly on one: a Durable Object alarm fires at
the due time, where the others promise "at or after nextDue, checked every tick".
Choosing a provider
shardedReminders() needs no second store, and it is the right choice at dev scale and for a
deployment holding hundreds of sleeping actors. Its cost is the bytes it rewrites: every
set rewrites a sixteenth of the cluster's reminders, and every tick loads every owned
record, due or not. So it scales with how many reminders exist, not with how many fire.
A deployment that keeps thousands of actors asleep on reminders should pass the indexed
provider its store already has — pgReminders, redisReminders or surrealReminders. There
an arm is O(log N) and a tick is O(due). Measured:
| Entries asleep | Sharded set p50 | Fires within a tick (sharded) | Indexed set p50 |
|---|---|---|---|
| 10 000 | 408 µs | 0.99 | 0.64 ms |
| 100 000 | 871 ms under load | 0.29 | 0.68 ms |
On a three-host cluster with 18 000 runs asleep, the lag between a reminder falling due and
its actor waking was 2 210 ms p50 / 48.2 s p99 on the sharded table and 338 ms / 878 ms
on redisReminders, at roughly half the Redis CPU. Neither arm lost a wake or failed a CAS at
any size — the ceiling is not contention, so adding hosts does not divide it.
Shorten the tick on an index. An empty tick on an indexed provider costs the same few
round trips whether ten thousand or a million reminders are asleep, so reminderTickMs can be
a few seconds there, where the sharded table wants the 30-second default. A durable wake's lag
is bounded by the tick, so that is the difference between a wake landing within a couple of
seconds and within fifteen.
You do not have to predict the crossover. HostStats.reminderShardEntriesMax reports the
largest shard record the host has ticked, and past about 1 000 entries a dev build warns once
that shardedReminders() has been outgrown.
Writing your own
A custom ActorReminders receives an ActorRemindersContext in bind(). Its deliver(ref, name) dispatches a due reminder, and the optional undelivered(ref, name, error) reports a
deliver() that rejected. Call it once per failed attempt — that is the only way a custom
implementation feeds remindersUndelivered, so on one that does not, a 0 there means "not
reported", not "none lost".
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.
