Lifecycle#

Activations come and go without you asking. What you control is how long they linger, how many a host will hold, and what happens at each end.

The hooks#

TypeScript
defineActor({
    type: 'Room',
    state: () => ({ members: [] as string[] }),
    async onActivate(ctx) {
        // runs after state is loaded and migrated, before the first turn
        ctx.index = new Map(ctx.state.members.map((m) => [m, true]));
    },
    async onDeactivate(ctx, reason) {
        // reason: 'idle' | 'shutdown' | 'capacity' | 'migrated' | …
    },
});

onActivate always observes migrated state. A throw from it fails activation with ActorActivationError and the call that triggered it rejects.

stateDiagram-v2
    [*] --> Activating: first call
    Activating --> Active: load, migrate, onActivate
    Activating --> Faulted: hook throws
    Faulted --> [*]: ActorActivationError
    Active --> Active: turns
    Active --> Deactivating: idle, shutdown, capacity, migrated
    Deactivating --> [*]: onDeactivate
Activation lifecycle

Deactivation is not deletion: the next call activates again and reads the state back. What is lost is anything kept outside ctx.state.

onDeactivate receives why, which matters because the reasons are not equivalent:

ReasonMeans
idleno calls for idleAfterMs — the ordinary case
shutdownthe host is stopping; in-flight turns have drained
capacitya maxActivations cap shed it (least-recently-used)
migrateda cluster moved it to another host

Idle collection#

idleAfterMs (default 20 minutes) is how long an activation survives without a call. The sweeper runs every sweepIntervalMs (default 60s); set either to 0 to disable.

Two things count as activity that people expect not to:

  • An open live watch keeps the actor alive, deliberately — so idle collection cannot deactivate an actor out from under a subscriber.
  • A running task holds a keep-alive until it finishes.

A ctx.timer tick does not keep the actor alive, and that is intentional: a volatile timer is a convenience, not a reason to pin an object in memory. If you need the actor to wake up later regardless, use a durable reminder.

Capping activations#

TypeScript
defaults: { maxActivations: 50_000 }

A soft LRU cap, enforced by the sweeper, with deactivation reason capacity. Soft is the operative word:

  • Busy actors and actors holding a keep-alive are never shed.
  • The live count may therefore exceed the cap.
  • State survives — a shed actor reactivates from storage on its next call.

It is a memory guardrail, not an admission-control mechanism. Watch byReason.capacity in metrics: a cap that is shedding constantly is either too low or hiding a key-cardinality problem.

Seeing what is live#

TypeScript
host.activations({ sortBy: 'queued', limit: 20 });

Returns { type, key, queued, ageMs, idleMs, keptAlive, tasks } per activation. sortBy accepts 'queued', 'age' or 'idle', and type filters to one actor type.

queued is the one to look at first: a persistently non-zero queue depth is a hotspot, and the fix is usually to shard the key or move I/O out of the turn — see Metrics for the queueMs versus turnMs distinction.

The same data is available over the wire through the ops endpoint and in the CLI dashboard.

Actor keys can be personal data. host.activations() returns them, which is why the ops endpoint keeps the activation list off by default and requires a secret.

Shutdown#

TypeScript
await host.stop({ timeoutMs: 10_000 });

Stops accepting new calls, drains in-flight turns, flushes pending write-behind saves, runs onDeactivate for every activation and then the app's onStop hooks in reverse order.

On Node, wire it to signals with attachSignalHandlers — see Running a host on Node. In a cluster, a graceful stop announces leaving before handing off, which is why liveness and readiness are allowed to disagree.

Next steps#