Skip to content

Snapshots

A PersistentActor reads its entire event log to recover. For an actor that’s lived for years and emitted 100 000 events, that’s a slow startup. Snapshots are a periodic dump of the current state — on recovery, the framework loads the latest snapshot and only replays events after it.

journal: E1 E2 E3 E4 E5 ... E150 ... E1000 ← all events ever
↑ ↑
recovery latest snapshot stored
starts here at seqNr=500
Without snapshots: replay 1000 events.
With snapshot @500: load snapshot, then replay E501..E1000.

The trade-off: snapshots cost disk space and write throughput, but buy bounded recovery time.

The actor declares when to snapshot via snapshotPolicy():

import { PersistentActor, everyNEvents } from 'actor-ts';
class Account extends PersistentActor<Cmd, Event, State> {
// ...
override snapshotPolicy() {
return everyNEvents(100); // snapshot every 100 events
}
}

everyNEvents(N) is the most common policy. The framework calls the policy function after every event apply; returning true triggers a snapshot of the current state to the snapshot store.

For custom policies:

override snapshotPolicy() {
return (seqNr, state, event) => {
// Snapshot on a specific event kind:
if (event.kind === 'finalized') return true;
// Or on a state property:
if (state.balance > 1_000_000) return true;
return false;
};
}

The signature: (seqNr, state, event) => boolean. seqNr is the just-applied event’s sequence number, state is the state after applying it, event is the event that triggered the call.

everyNEvents(N)

The numbers that matter:

  • Recovery time — how long replay-from-snapshot takes. Lower N means faster recovery.
  • Snapshot write cost — every snapshot serializes the full state. For large states, this is non-trivial work. Higher N amortizes that cost across more events.
  • Storage — snapshots take space, even with the “delete older” policy.

Typical numbers:

  • Small state, low write rateeveryNEvents(1000). A thousand-event replay is still fast; snapshots are rare.
  • Medium state, moderate write rateeveryNEvents(100). Sensible default.
  • Large state, high write rateeveryNEvents(1000) with fast journal, OR consider switching to DurableStateActor if events aren’t useful.

Don’t over-tune. everyNEvents(100) is a reasonable starting point; measure recovery latency in production before changing.

Snapshots are stored in a separate snapshot store, configured at the persistence-extension level:

import { PersistenceExtensionId, InMemoryJournal, InMemorySnapshotStore } from 'actor-ts';
system.extension(PersistenceExtensionId).configure({
journal: new InMemoryJournal(),
snapshotStore: new InMemorySnapshotStore(),
});

Built-in snapshot stores:

StoreWhen
InMemorySnapshotStoreTests, dev.
SqliteSnapshotStoreSingle-node production.
ObjectStorageSnapshotStoreFilesystem / S3 with optional encryption.
CachedSnapshotStoreWraps another store with a read-through cache.

The journal and the snapshot store are independent; you can mix them (e.g., Cassandra journal + object-storage snapshot store).

A snapshot is { persistenceId, sequenceNr, state }. The store typically retains:

  • The latest snapshot per persistenceId.
  • Optionally a few older ones, in case the latest is corrupt (configurable per store).

When the actor recovers, the store returns the latest snapshot for the actor’s persistenceId. Older snapshots aren’t loaded unless you explicitly ask via the store’s lower-level API.

preStart:
├── load latest snapshot for persistenceId
│ ├── found at seqNr=500 → state = snapshot.state, seq = 500
│ └── not found → state = initialState(), seq = 0
├── read events from journal starting at seq+1
│ └── for each event: state = onEvent(state, event); seq++
└── onRecoveryComplete(state)

onEvent is still pure — same rule as without snapshots. The snapshot is just where replay starts; everything after is event replay as usual.

When the state shape changes (a field is renamed, a value is restructured), old snapshots can’t be deserialized into the new shape. Two options:

  1. Delete old snapshots — the next recovery replays from scratch, building the new state shape from the (still-current) event log.

  2. Use a snapshot adapter to upcast old snapshots on load:

    class V1ToV2SnapAdapter implements SnapshotAdapter<StateV2> {
    upcast(stored: unknown, version: number): StateV2 {
    if (version === 1) return migrateSnapshot(stored as StateV1);
    return stored as StateV2;
    }
    }
    class Account extends PersistentActor<...> {
    override snapshotAdapter() { return new V1ToV2SnapAdapter(); }
    }

With an adapter set, snapshots are persisted in a { _v, _t, _e } envelope; on load, the adapter migrates older versions before returning the state.

See Migration overview for the broader migration strategy (event adapters + snapshot adapters together).

A PersistentActor without a snapshot policy never snapshots — recovery replays every event from the beginning of time. For an actor that emits 10 events total, that’s fine; for an event-sourced cart that accumulates 10 000 events over a user’s lifetime, that’s a recovery-time problem.

The framework doesn’t auto-snapshot. If you don’t set a policy, you don’t get snapshots — snapshotPolicy() defaults to () => false.

The SnapshotStore and SnapshotPolicy API references cover the full surface.