Snapshots, Versioning, and Event Schema Evolution
Speed up rehydration with snapshots and evolve event schemas without breaking historical data.
Snapshots, Versioning, and Event Schema Evolution is a free Node.js Backend Development Bootcamp lesson on CoddyKit — lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Node.js Backend Development Bootcamp learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Why Rehydration Gets Slow
In Event Sourcing, the current state of an aggregate is rebuilt by replaying every event from the start of its stream. This is called rehydration.
For a fresh bank account with 12 events, replaying them is instant. But a long-lived aggregate (an account open for 5 years, a shopping cart with thousands of edits) can accumulate tens of thousands of events. Loading and folding all of them on every command becomes the bottleneck.
- I/O cost: reading thousands of rows from the event store
- CPU cost: folding each event through the reducer
- Latency: every write must first rehydrate before validating the next command
The standard fix is a snapshot: a periodic, serialized copy of the aggregate state so you only replay events after it.
Rehydration as a Fold
Before optimizing, make rehydration explicit. An aggregate's state is a left fold over its events: state = events.reduce(apply, initialState).
Here is a minimal account aggregate. The apply function is a pure reducer — given the previous state and one event, it returns the next state. Notice it never mutates; it returns new objects so it stays easy to reason about and test.
function apply(state, event) {
switch (event.type) {
case 'AccountOpened':
return { ...state, balance: 0, version: state.version + 1 };
case 'MoneyDeposited':
return { ...state, balance: state.balance + event.amount, version: state.version + 1 };
case 'MoneyWithdrawn':
return { ...state, balance: state.balance - event.amount, version: state.version + 1 };
default:
return state;
}
}
function rehydrate(events) {
const initial = { balance: 0, version: 0 };
return events.reduce(apply, initial);
}
const events = [
{ type: 'AccountOpened' },
{ type: 'MoneyDeposited', amount: 100 },
{ type: 'MoneyWithdrawn', amount: 30 },
];
console.log(rehydrate(events)); // { balance: 70, version: 3 }What a Snapshot Actually Is
A snapshot is a saved tuple of (aggregateId, version, serializedState). The version is the event sequence number the state reflects — this is the critical part.
To rehydrate with a snapshot:
- Load the latest snapshot for the aggregate
- Seed the fold with the snapshot state instead of
initialState - Replay only events with version > snapshot.version
Key rule: a snapshot is a derived cache, never the source of truth. The event stream remains authoritative. If a snapshot is lost or corrupted, you can always rebuild it by replaying from zero.
Rehydrating From a Snapshot
This version loads from a snapshot when one exists, then folds only the newer events. The snapshot already encodes version, so we know exactly where to resume.
In production the snapshot and the events come from your store; here they are passed in so the logic runs standalone.
function apply(state, event) {
switch (event.type) {
case 'MoneyDeposited':
return { ...state, balance: state.balance + event.amount, version: event.version };
case 'MoneyWithdrawn':
return { ...state, balance: state.balance - event.amount, version: event.version };
default:
return { ...state, version: event.version };
}
}
function loadFromSnapshot(snapshot, allEvents) {
const start = snapshot ? snapshot.state : { balance: 0, version: 0 };
const fromVersion = snapshot ? snapshot.version : 0;
const newer = allEvents.filter((e) => e.version > fromVersion);
return newer.reduce(apply, start);
}
const snapshot = { version: 3, state: { balance: 70, version: 3 } };
const events = [
{ type: 'MoneyDeposited', amount: 100, version: 1 },
{ type: 'MoneyWithdrawn', amount: 30, version: 3 },
{ type: 'MoneyDeposited', amount: 50, version: 4 },
];
console.log(loadFromSnapshot(snapshot, events)); // { balance: 120, version: 4 }When to Take a Snapshot
You don't snapshot on every event — that defeats the purpose. Common strategies:
- Every N events: e.g. snapshot whenever
version % 100 === 0. Simple and predictable. - By replay cost: snapshot when the gap between current version and last snapshot exceeds a threshold.
- Async / background: a separate worker subscribes to the stream and writes snapshots off the hot path, so writes never wait on snapshotting.
Tune N empirically. Too low wastes storage and write throughput; too high means long replays. For many systems N = 50–200 is a reasonable starting point.
const SNAPSHOT_EVERY = 100;
function shouldSnapshot(currentVersion, lastSnapshotVersion) {
// Snapshot when we've crossed another multiple of N
const since = currentVersion - lastSnapshotVersion;
return since >= SNAPSHOT_EVERY;
}
console.log(shouldSnapshot(100, 0)); // true
console.log(shouldSnapshot(140, 100)); // false
console.log(shouldSnapshot(205, 100)); // trueThe Hidden Trap: Snapshots Are Versioned Too
Here is the subtle danger. A snapshot stores the shape of your state at the moment it was written. If you later change how state is structured (rename a field, add a required property, change a type), old snapshots become incompatible.
Imagine you stored { balance: 70 }, then refactored to { balanceCents: 7000 }. A six-month-old snapshot still has balance, and your new apply code expects balanceCents. Rehydration silently produces wrong state.
Rule: tag every snapshot with a schemaVersion. On load, if the snapshot's schema version doesn't match the current code, discard it and replay from the events (which are upcasted — covered next). Events are forever; snapshots are disposable.
Guarding Snapshot Schema Version
This guard checks the snapshot's schemaVersion before trusting it. If it's stale, we fall back to a full replay. Because events are the source of truth, falling back is always safe — it's just slower for that one load, and the next snapshot will be written in the new shape.
const CURRENT_SCHEMA = 2;
function chooseStartState(snapshot) {
if (snapshot && snapshot.schemaVersion === CURRENT_SCHEMA) {
return { state: snapshot.state, fromVersion: snapshot.version };
}
// Stale or missing snapshot: replay everything
return { state: { balanceCents: 0, version: 0 }, fromVersion: 0 };
}
const stale = { schemaVersion: 1, version: 300, state: { balance: 70 } };
const fresh = { schemaVersion: 2, version: 300, state: { balanceCents: 7000, version: 300 } };
console.log(chooseStartState(stale).fromVersion); // 0 -> full replay
console.log(chooseStartState(fresh).fromVersion); // 300 -> fast pathEvolving Event Schemas: The Real Constraint
Events are immutable historical facts. You can never go back and edit a MoneyDeposited event from two years ago — it really happened in that shape. Yet your code must keep evolving.
So when business needs change, you cannot rewrite history. Instead you have a small set of disciplined techniques:
- Weak schema / additive change: only ever add optional fields; never remove or repurpose existing ones.
- Upcasting: transform old event versions into the current shape at read time.
- New event type: when a change is too big, introduce a new event type and stop emitting the old one.
The golden rule: old events must remain readable forever. Every consumer reads through an upcaster, so the rest of your code only ever sees the latest shape.
Upcasting Old Events at Read Time
Upcasting means: as each event is read from the store, run it through a function that migrates older versions up to the current shape before it reaches apply. Each event carries an eventVersion so the upcaster knows what to do.
Example evolution: v1 deposits had a single amount in dollars. v2 splits money into currency + integer amountCents. Old v1 events get upcasted on the fly — without ever touching the stored data.
function upcast(event) {
if (event.type === 'MoneyDeposited' && (event.eventVersion || 1) === 1) {
return {
...event,
eventVersion: 2,
currency: 'USD',
amountCents: Math.round(event.amount * 100),
};
}
return event;
}
const stored = [
{ type: 'MoneyDeposited', amount: 100 }, // legacy v1
{ type: 'MoneyDeposited', eventVersion: 2, currency: 'USD', amountCents: 5000 },
];
const upcasted = stored.map(upcast);
console.log(upcasted[0]); // { ...amountCents: 10000, currency: 'USD', eventVersion: 2 }
console.log(upcasted.every((e) => typeof e.amountCents === 'number')); // trueChaining Upcasters Across Multiple Versions
Schemas evolve more than once. The clean pattern is a chain of single-step upcasters: v1→v2, v2→v3, v3→v4. An event enters at its stored version and is pushed forward step by step until it reaches the latest version. This keeps each migration small, testable, and independent.
Never write a giant v1→v4 function — you'd duplicate logic and it rots fast. Compose small steps instead.
const upcasters = {
1: (e) => ({ ...e, eventVersion: 2, amountCents: Math.round(e.amount * 100) }),
2: (e) => ({ ...e, eventVersion: 3, currency: e.currency || 'USD' }),
3: (e) => ({ ...e, eventVersion: 4, source: e.source || 'unknown' }),
};
const LATEST = 4;
function upcastToLatest(event) {
let e = { ...event, eventVersion: event.eventVersion || 1 };
while (e.eventVersion < LATEST) {
e = upcasters[e.eventVersion](e);
}
return e;
}
const legacy = { type: 'MoneyDeposited', amount: 42 };
const result = upcastToLatest(legacy);
console.log(result.eventVersion); // 4
console.log(result.amountCents); // 4200
console.log(result.currency); // USD
console.log(result.source); // unknownPutting It Together: Safe, Fast Loading
A production rehydration path combines everything:
- Load the latest snapshot; discard it if its
schemaVersionis stale - Read events after the (valid) snapshot version
- Upcast every event to the latest shape
- Fold them onto the start state
- Optionally write a new snapshot if the replay gap was large
This gives you O(events-since-snapshot) loads in the happy path, automatic correctness when state shape changes, and forward-compatible event reading. Snapshots make it fast; upcasting keeps it correct as the system evolves.
Operational tip: when you ship a breaking state refactor, you don't need to migrate snapshots — just bump CURRENT_SCHEMA and let the next load rebuild them lazily.
Quick Check
Test your understanding of the safe-evolution strategy.
Recap
You learned how to make Event Sourcing both fast and durable over time:
- Rehydration is a left fold over events; long streams make it slow.
- Snapshots store
(aggregateId, version, state)so you replay only events after them — a derived cache, never the source of truth. - Snapshot strategy: take one every N events or by replay-gap, ideally on a background worker.
- Tag snapshots with
schemaVersion; discard and full-replay when state shape changes — no snapshot migration needed. - Events are immutable. Evolve safely with additive-only changes, upcasting old events to the latest shape at read time, or introducing new event types.
- Chain single-step upcasters (v1→v2→v3) so every consumer sees only the current shape.
Snapshots give you speed; versioning and upcasting give you the freedom to evolve without ever breaking history.
Frequently asked questions
Is the “Snapshots, Versioning, and Event Schema Evolution” lesson free?
Yes — the full text of “Snapshots, Versioning, and Event Schema Evolution” is free to read here on the web, and the Node.js Backend Development Bootcamp course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Node.js Backend Development Bootcamp course, upgrade to CoddyKit PRO.
What will I learn in “Snapshots, Versioning, and Event Schema Evolution”?
Speed up rehydration with snapshots and evolve event schemas without breaking historical data. You practise Node.js Backend Development Bootcamp with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start Node.js Backend Development Bootcamp?
No prior experience is required. Node.js Backend Development Bootcamp on CoddyKit is structured for beginners through advanced learners; this is — lesson 4 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Snapshots, Versioning, and Event Schema Evolution” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this Node.js Backend Development Bootcamp lesson?
Yes. Every Node.js Backend Development Bootcamp lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Events as the Source of Truth and the Append-Only Log
- Aggregates, Commands, and Domain Event Modeling
- Building Read Models and Projections
- Snapshots, Versioning, and Event Schema Evolution