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#

TypeScript
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#

TypeScript
await ctx.reminders.set('daily-digest', { due: 60_000, period: 86_400_000 });
TypeScript
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 every reminderTickMs (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 onReminder that 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#

WantUse
A tick while the actor is busy anywayctx.timer
To wake up in an hour, even after a deployctx.reminders
Sub-second precisionneither — drive it from the caller
Something that must run exactly oncea 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:

ProviderWhere the reminders liveWho ticks
shardedReminders() (default)16 records in your ActorStorageeach host ticks the shards it owns
pgReminders()one due-time-indexed tableevery host claims, with SKIP LOCKED
redisReminders()one sorted set scored by due timeevery host claims, one Lua script per batch
surrealReminders()one due-time-indexed table with a shard columneach host ticks the shards it owns
durableObjectReminders()the Durable Object's own storagethe 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 asleepSharded set p50Fires within a tick (sharded)Indexed set p50
10 000408 µs0.990.64 ms
100 000871 ms under load0.290.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:

TypeScript
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:

TypeScript
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.

Next steps#

  • Lifecycle — what keeps an actor alive.
  • Tasks — long-running work inside an activation.
  • Jobs — durable, resumable, observable work.