Event Sourced Aggregate Slow to Load With Many Events: Snapshots
Architecture · Advanced · 7 min read · published
This article was written by Claude (Anthropic) and published automatically. Published by Osama Fayyaz.
What this solves: Commands on long-lived event-sourced aggregates get slower every month because every load replays the full stream. Here's how snapshots fix that safely.
The Forces at Play
If your event sourced aggregate is slow to load once it has many events, you have hit the main scaling cost of event sourcing. Every command rebuilds state by replaying the aggregate's entire stream. A shopping cart with 12 events loads in a millisecond. A bank account, a long-running subscription, or an IoT device with 80,000 events can take 400 ms to rehydrate before you even check a business rule. That cost grows forever.
Several pressures pull in different directions:
- Correctness wants the full history. The event log is the source of truth, and replaying it is how you guarantee that state matches what happened.
- Latency wants constant-time loads. Command handlers sit on the request path. Linear growth in load time eventually becomes a p99 problem, and later a timeout problem.
- Concurrency wants short critical sections. Optimistic concurrency checks the expected stream version on append. A long load widens the window in which another writer can append first, so you get more
WrongExpectedVersionretries, and each retry pays the full replay again. - Evolvability wants freedom to change the reducer. Any cached form of state silently encodes the logic that produced it.
Snapshots resolve the latency pressure. Done carelessly, they quietly break the correctness and evolvability pressures. The rest of this piece is about getting the speedup without that damage.
The Shape
The key structural decision is that the snapshot store is a cache beside the event store, not a layer in front of it. The write path never depends on a snapshot existing.
flowchart LR CMD[Command Handler] -->|load streamId| REPO[Aggregate Repository] REPO -->|1. latest snapshot where schemaVersion = current| SS[(Snapshot Store<br/>disposable cache)] REPO -->|2. read events after snapshot.streamVersion| ES[(Event Store<br/>source of truth)] REPO -->|3. fold events onto state| AGG[Aggregate State] CMD -->|decide| AGG CMD -->|4. append with expectedVersion| ES ES -->|5. committed event feed| SNAP[Snapshotter worker] SNAP -->|policy: every N events or slow load| SNAP SNAP -->|read snapshot + tail, fold| ES SNAP -->|6. write snapshot at committed version| SS
There are two loops. The command loop (steps 1-4) reads a snapshot if a usable one exists and otherwise falls back to a full replay. The snapshot loop (steps 5-6) runs asynchronously and only ever sees committed events. Because snapshot writes never sit on the command's critical path, a failing snapshot store degrades latency but never correctness.
How Data Flows Through It
Take a WithdrawFunds command for account acct-42, whose stream has reached version 48,213.
- The command handler asks the repository for
acct-42. - The repository fetches the latest snapshot. It finds one at
streamVersion 48,000withschemaVersion 7, which matches the running code's version, so the snapshot is usable. - It deserializes the snapshot state (balance, overdraft limit, open holds) and reads only events 48,001 to 48,213 from the event store. That is 213 events instead of 48,213.
- It folds those 213 events through the same
applyfunction used for full replays. Load time drops from ~350 ms to ~4 ms. - The handler checks the business rule (sufficient funds), produces
FundsWithdrawn, and appends withexpectedVersion = 48213. If someone else appended first, the append fails and the handler retries. The retry is cheap now too. - The event store publishes the committed event. The snapshotter sees that
acct-42is now 214 events past its last snapshot. That is under its threshold of 500, so it does nothing. - A few hundred events later, the snapshotter loads snapshot plus tail, folds, and writes a new snapshot tagged
{streamVersion: 48,501, schemaVersion: 7}. Older snapshots can be garbage-collected.
A minimal snapshot contract looks like this:
interface Snapshot<S> {
streamId: string;
streamVersion: number; // last event folded into state (committed only)
schemaVersion: number; // bumps when state shape OR reducer semantics change
takenAt: string;
state: S; // serialized aggregate state
}
interface SnapshotStore<S> {
latest(streamId: string, schemaVersion: number): Promise<Snapshot<S> | null>;
save(s: Snapshot<S>): Promise<void>; // idempotent upsert, last-writer-by-version wins
}
What Each Piece Owns
Event store. It owns the truth and the concurrency check (expectedVersion). It does not own snapshots. Storing them in the same stream as events couples cache lifecycle to immutable history.
Snapshot store. It owns fast access to recent state. It deliberately owns nothing that cannot be regenerated. You should be able to truncate it at 3 a.m. and lose only latency. If deleting snapshots would lose data, it has stopped being a cache.
Repository. It owns rehydration: choosing a snapshot, rejecting version mismatches, reading the tail, and folding. It does not decide when to snapshot. Mixing the two puts a write onto every read.
Snapshotter. It owns the policy (every N events, or when measured load time exceeds a threshold) and writes only from committed events. It does not serve requests, and it must never block them.
Aggregate / reducer. It owns apply(state, event). The reducer has no idea snapshots exist. One fold function serves both full replay and snapshot-plus-tail, which is what keeps the two paths equivalent.
Where It Breaks Down
Reducer drift is the first and quietest failure. Suppose you change how interest accrues and deploy. Every existing snapshot holds state computed by the old logic, and only new events get the new logic. Nothing throws. Balances are simply wrong for exactly the long-lived aggregates that have snapshots. The fix is to version snapshots by semantics, not just by class shape, and to treat a mismatch as a miss. Many teams hash the reducer module in CI and fail the build if the hash changes without a version bump.
Mass invalidation creates a thundering herd. Bumping the version turns every load into a full replay at once. On large systems, plan to roll a version bump out with a background rebuild of the hottest streams first, or accept a temporary p99 spike.
Snapshotting inside the command path. Writing a snapshot synchronously after every Nth append adds write latency and a second failure point. Worse, if the snapshot write succeeds but the event append fails, the snapshot now describes a version that does not exist. Only snapshot from committed events.
The snapshot itself becomes huge. If state includes unbounded collections (every transaction ID ever seen, for deduplication), the snapshot grows linearly and you have moved the problem rather than solved it. That points to an aggregate boundary problem, not a snapshot tuning problem.
The operational burden lands on the snapshotter. You now run a projection-like worker with its own lag, checkpointing, and alerting. If it falls behind, nothing breaks, but loads slowly creep back toward full replay. Alert on the "events since last snapshot" distribution, not just on worker health.
When This Is Overkill
Most aggregates never need snapshots. If your 99th-percentile stream length is a few hundred events, replay costs single-digit milliseconds, and a snapshot store is pure complexity.
The smaller design that is usually correct is to shorten the streams. Model the domain with natural closing points:
- an account per accounting period, opened with a
PeriodOpened { carriedBalance }event - a subscription per billing cycle
- a device per day
This "close the books" pattern keeps every stream bounded and makes the carried-forward state an explicit domain event, rather than a hidden cache with versioning hazards.
The signal that you have outgrown that: you instrument events-per-load and load duration per aggregate type, and a stream type shows unbounded growth with no meaningful business closing point. A shared ledger or a long-running device is a typical example. When aggregate load time becomes a visible share of command p99, add snapshots for that aggregate type only, not globally.
Key takeaway: Treat snapshots as a disposable, versioned cache of the event stream: write them off the command path, tag them with a schema version, and make deleting them always safe.
Real-world challenge
After a release that changed how loyalty points expire, support tickets arrive: long-standing customers see wrong point balances, but customers who signed up in the last few weeks are fine. Replaying a broken customer's stream in a test harness gives the correct balance. Nothing errors in the logs, and the event store data looks intact.
Diagnosis. The split between new and old customers is the clue. New customers have short streams and no snapshot yet, so they are rehydrated from events by the new reducer. Old customers load a snapshot built by the old expiry logic and then apply only the recent events on top. The snapshot froze the old interpretation of history. The test harness replays from event 0, which is why it looks correct.
Fix.
- Version snapshots by the reducer logic, not only by the class shape. Expiry logic changed, so the version must change even though no field was added.
- Make the repository treat a version mismatch as a cache miss.
- Let the async snapshotter re-snapshot lazily, or run a background rebuild for the hottest streams.
const SNAPSHOT_VERSION = 7; // bump on ANY reducer semantics change
const snap = await snapshots.latest(streamId);
const usable = snap && snap.schemaVersion === SNAPSHOT_VERSION;
let state = usable ? deserialize(snap.state) : initialState();
const from = usable ? snap.streamVersion + 1 : 0;
for await (const e of events.read(streamId, from)) state = apply(state, e);
Prevent it. Add a CI check that hashes the reducer module and fails if the hash changes without a SNAPSHOT_VERSION bump. Also run a nightly job that samples streams and compares snapshot+tail state against full-replay state.