Every system in this series so far has stored the current state: the balance is 100, the avatar is avatar-v3.png, the order is in the shipped state. Every time something changes, we overwrite the old value. This lesson reverses that: store the sequence of things that happened, and treat the current state as merely something computed from that sequence.

The lab builds a real event store on PostgreSQL with 200,000 events. Three measurements stand out. First, a projection that is not idempotent, run a second time, produces a balance exactly 2.00 times too high โ€” 9,132,892 instead of 4,566,446 โ€” with no error thrown at all; the read model is simply wrong. Second, rebuilding the entire read model from 200,000 events takes 235 ms when replayed in application code, meaning replay at this scale is routine, not an emergency operation. Third, a snapshot makes a long aggregate 6.6 times faster but makes a short aggregate slower โ€” it is a conditional tool, not a mandatory step.

โ„น๏ธ The measurement machine
Apple M1 Max, 10 cores, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. PostgreSQL 18 in a native arm64 container, limited to 1 CPU and 512 MB RAM; the worker runs node:22-alpine, no dependencies, reusing minipg.js โ€” the hand-written PostgreSQL client from Lesson 7.

All the figures come from worker/eventstore.js with 200,000 events spread across 1,000 aggregates. Both the database and the worker sit on the same machine, so the millisecond figures here do not include real network cost; read them as ratios between the options, not as absolute values for your own system.

14.1 State is the result of history

The same bank account, two ways to store it. The familiar way: one row, a balance = 100 column, every transaction is an UPDATE. The event-sourcing way: three rows, Deposited(+50), Deposited(+70), Withdrawn(-20), and 100 is the number we add up when we need it.

This bank account is an example of a concept that recurs throughout the lesson: an aggregate โ€” the unit that an event history is grouped around, each with its own sequence of events and its own version number, entirely separate from every other unit. Your account is one aggregate; someone else's account is a different aggregate with its own event sequence. An order, a shopping cart, an insurance policy โ€” each of those is an aggregate in exactly this sense too. By contrast, a row of system configuration or an entry in a product catalog usually does not need to become an aggregate this way: nobody needs to know who changed a product's description three months ago โ€” see the pitfall right below.

The difference is not which way is "more correct" but what each preserves and what each loses. From a sequence of events, you can always compute the current state. From the current state, you cannot reconstruct the history โ€” that information was overwritten by the UPDATE and no longer lives anywhere. This transformation is one-way, and event sourcing's entire value sits in the direction that gets lost.

The same three transactions โ€” two ways to store them, two different things left afterward STORE STATE โ€” every transaction is an UPDATE balance = 0 โ†’ balance = 50 โ†’ balance = 120 โ†’ balance = 100 โ† only this box still exists The three faded boxes above have been overwritten. There is no way to know the balance was ever 120, who withdrew 20, or when. The question "what was the average balance last month" cannot be answered โ€” the data needed to answer it has been erased. STORE EVENTS โ€” only INSERT, never UPDATE Deposited +50 Deposited +70 Withdrawn โˆ’20 all three are still intact fold up โ†’ 100 State is something COMPUTED, not something stored. To know the balance on 7/3, fold up to that date.
From the log you can always compute the state. From the state you cannot compute the log back โ€” this transformation is one-way.

Three abilities come nearly free from storing data this way. A full audit trail: every change is a row with a timestamp and a cause, not an updated_at column that just says "something changed". Debugging by rewinding: fold the log up to the exact moment right before the incident and look at the state then, instead of guessing from application logs. Answering questions nobody thought to ask at design time โ€” and this is the most valuable ability: when the business side asks "how many people deposited money and then withdrew it all within 24 hours", with a state table you have to answer "we don't store that data"; with an event log, the answer is already sitting in the history.

โš ๏ธ Pitfall: applying event sourcing to ordinary CRUD
For a settings table, a product catalog, a user profile form โ€” nobody needs to know what the "description" field used to say three months ago. In exchange, every engineer who touches that part of the system has to understand events, projections, replay, versioning; every simple query becomes an architecture question; and a SELECT that should have been one line now has to go through a read model.

The test question: has anyone ever asked you "what was this value before"? If not, and there is no legal requirement to keep history, you are paying a very high price for an ability nobody uses. Event sourcing usually only earns its keep for a handful of core aggregates โ€” money, orders, permissions โ€” not for the whole system.

14.2 Events are immutable facts

One very small naming convention decides almost everything that follows: events are named in the past tense. OrderPlaced, not PlaceOrder. The second one is a command โ€” a request, which can be rejected, which can fail. The first one is a fact โ€” it already happened, and there is no way to make it not have happened.

Command Event
Naming PlaceOrder, Withdraw OrderPlaced, Withdrawn
Can it be rejected? Yes โ€” validation, insufficient balance, insufficient permission No โ€” it already happened
Number of recipients Exactly one โ€” the handler that processes it As many as you like โ€” every projection is a listener
Fixing a mistake Don't send it, or send it again Write a compensating event

The last row is the one violated most often. When you discover an event recorded the wrong amount, the natural reflex is to UPDATE it to the correct value. Don't. Write a new AmountCorrected event โ€” exactly the way an accountant never erases an entry but always writes a reversing entry. The reason is not purity but very practical: every projection already built from the old event still exists, and every projection built in the future will read the corrected one. The two worlds no longer agree, and nobody can trace why.

โš ๏ธ Pitfall: running UPDATE on the event table to "quickly fix a bug"
This is the most thoroughly destructive mistake in this lesson, and it always starts with good intentions: one event was recorded wrong, fixing one row seems like it settles it. But the entire model rests on exactly one assumption โ€” the log is immutable. Drop that assumption and replay no longer reconstructs the past faithfully, two read models built at two different times give two different results, and the audit trail โ€” the main reason you chose event sourcing โ€” becomes something you cannot trust.

The cheapest and most effective defense: revoke UPDATE and DELETE privileges on the event table at the database layer, so even you cannot do it, not even at 3 in the morning. This lab's entire worker/eventstore.js does not have a single UPDATE statement touching the events table โ€” that is a self-imposed constraint, and it is the precondition for every number in this lesson being reproducible.

Immutability throws in one more thing for free: optimistic concurrency control. If every event carries a version number within its aggregate, and there is a UNIQUE(aggregate, version) constraint, then two processes that both read "the account is at version 1" and both try to write version 2 will produce exactly one winner. The lab runs exactly that scenario:

ket_qua_concurrent.json
{
  "role": "concurrent",
  "ketQua": [
    { "who": "A", "ketQua": "THANG โ€” su kien duoc ghi" },
    { "who": "B", "ketQua": "THUA โ€” bi tu choi",
      "loi": "postgres: duplicate key value violates unique constraint \"events_aggregate_version_key\"" }
  ],
  "soSuKienCuoiCung": 2,
  "ghiChu": "Dung 2 su kien: version 1 va version 2. Nguoi thua KHONG de mat du lieu cua nguoi thang."
}

Worth comparing with an ordinary state table: two UPDATE balance = ... commands running at the same time mean the later one overwrites the earlier one, both report success, and nobody knows a change went missing. Here, the loser gets an error immediately โ€” and "getting an error immediately" is something you can act on: reread the latest version, reapply the business rule, retry. This is exactly the lost update that Lesson 10 had to use a distributed lock to avoid; here it is blocked by a single UNIQUE constraint, with no lock at all.

14.3 Projections: read the log, build a read table

Nobody queries the event log directly to render a UI โ€” folding up several thousand events on every page load is not viable. Instead, a projection worker reads the log and builds tables that are already optimized for each screen. A balances table is one projection. A recent-transactions list is another projection. A monthly report is a third projection. All three are built from the same log.

One log โ€” many read models โ€” and how replay fixes a bug EVENT LOG append-only seq 1 ยท Deposited seq 2 ยท Withdrawn seq 3 ยท Deposited โ‹ฎ 200,000 events read_balances for the account screen recent_transactions for the history screen monthly_report added today โ€” replay makes it available right away You discover a projection has been computing tax wrong for 6 months โ€” how do you fix it? โ‘  fix the projection code โ†’ โ‘ก delete the read model โ†’ โ‘ข replay from the start 235 ms for 200,000 events Without an event log, step โ‘ข does not exist: the wrong data is the only data you have, and you have to fix it row by row by hand.
A read model is disposable โ€” build it, use it, throw it away. That is what makes the difference when there is a bug.

Lab measures both ways to build a read model from 200,000 events:

Replay method Time Speed When it matches reality
One SQL GROUP BY 43 ms ~4.6 million events/s Only when the projection folds down to one aggregation
Pull into the application and fold in code 235 ms (174 ms is pulling the data) ~850 thousand events/s Closer to reality โ€” a real projection has business rules, branching by event type

The number worth remembering is the second one, and it says this: at a scale of hundreds of thousands of events, rebuilding the entire read model is a matter of a few hundred milliseconds. At a scale of hundreds of millions, the same speed gives a few minutes. Both fall in the range of "runs during business hours", not "needs a maintenance window scheduled". That is why "fix the bug and replay" is a true statement rather than a slogan.

But it is only true when the projection is idempotent. The lab runs exactly one naive projection โ€” the kind that simply accumulates every event it reads โ€” and then runs it a second time:

ket_qua_replay2x.json
// IDEMPOTENT=0 โ€” projection ngay tho
{
  "soSuKienTrongLog": 200000,
  "dungPhaiLa": 4566446,
  "lan1": { "eventsApplied": 200000, "ms": 44, "tongSoDu": 4566446 },
  "lan2": { "eventsApplied": 200000, "ms": 39, "tongSoDu": 9132892 },
  "ketLuan": "SAI โ€” read model lech 4566446 (gap 2.00 lan)"
}

// IDEMPOTENT=1 โ€” co checkpoint `projection_state.last_seq`
{
  "lan1": { "eventsApplied": 200000, "ms": 43, "tongSoDu": 4566446 },
  "lan2": { "eventsApplied": 0,      "ms": 1,  "tongSoDu": 4566446 },
  "ketLuan": "DUNG โ€” replay bao nhieu lan cung ra cung ket qua"
}

The most alarming detail is not the doubled number but the line "ms": 39: the wrong run succeeded, faster than the first run, with no warning at all. The read model is simply wrong from that second onward. The idempotent version applies 0 events on the second run and finishes in 1 ms โ€” because it remembers which seq it already processed.

โš ๏ธ Pitfall: a non-idempotent projection
You will replay unintentionally, not on purpose. A worker gets restarted mid-run, a deploy runs two versions at once for a few seconds, a cron job overlaps with itself, a message queue redelivers an event already delivered (Lesson 12 โ€” at-least-once is unavoidable). Every one of those situations leads to the same thing: one event gets applied twice.

This is exactly why Lesson 11 had to come before this lesson. The two cheapest ways to be idempotent: store a checkpoint (the last_seq already processed, as the lab does), or write the update as setting a value instead of accumulating (SET balance = <recomputed total> instead of SET balance = balance + x). Accumulation is never idempotent.

14.4 CQRS: splitting the write path from the read path

Once you have an event log and projections, the system naturally splits into two halves with two quite different sets of requirements. CQRS (Command Query Responsibility Segregation) is just the name for acknowledging that split and designing for it properly.

WRITE path (command) READ path (query)
What it does Validate business rules, produce an event, append it Read a table already built for the right screen
Optimized for Correctness and order Read speed โ€” data duplication is perfectly fine
How it scales Hard โ€” order within each aggregate must be preserved Easy โ€” replicate as many read copies as you want (Lesson 7)
Consistency Strong โ€” enforced by the database Eventual โ€” always a beat behind the log

The last row is the one you pay for. The read model is built after the event has been written, so there is always a window where the log already has the event but the read table does not yet. That window is usually a few milliseconds and nobody notices โ€” except exactly one person: the one who just clicked the button.

Two paths, fully separated โ€” and the gap sits in between WRITE PATH โ€” command Withdraw(20) โ†’ validate the rule โ†’ append Withdrawn STRONG consistency: UNIQUE(aggregate, version), enforced by the database. Hard to scale: order within each aggregate must be preserved. READ PATH โ€” query GET /balance โ†’ read_balances โ†’ 1 query EVENTUAL consistency: this table is built AFTER the event has been written. Easy to scale: replicate as many read copies as you like, duplicated data is fine. projection worker The gap between the two paths is exactly where the person who just clicked the button lands The event is already in the log โ€” but the read table has not caught up yet. The user reloads the page and sees the old balance, concludes "the transaction failed," and clicks again. No pin-to-primary trick can save you here.
A read model is not a slow copy of the write table โ€” it has an entirely different structure, so Lesson 7's fix does not apply.
๐Ÿšจ This is Lesson 7's read-your-writes bug, at the architecture level
Lesson 7 ran into exactly this phenomenon between a primary and a replica: write, then read immediately, and see stale data. There, a neat fix existed โ€” pin the just-written user's read to the primary for a few hundred milliseconds.

With CQRS, that fix does not exist, because the read model is not a slow copy of the write table โ€” it has an entirely different structure, and there is no "primary" to pin back to. Three approaches are commonly used instead: (1) the write path always returns the computed result, and the UI uses it without reading again; (2) optimistic update โ€” the UI draws the new state itself and syncs later; (3) the write path returns a version number, and the UI waits until the read model reaches that version before reading. What they share: all three must be designed from the start at the UI layer โ€” they cannot be patched in afterward.
๐Ÿ’ก CQRS does not require event sourcing
These two concepts get bundled together far too often. You can absolutely do CQRS on an ordinary relational database: the write path works against normalized tables, the read path uses a few denormalized tables kept up to date by triggers or a background job. That is still CQRS, and it is much cheaper.

The reverse is true too: you can have event sourcing without CQRS if you only need a single read model. The expensive part is event sourcing, not CQRS โ€” so if your problem is simply "reads are slow", try CQRS first and keep your current way of storing state.

14.5 Running it for real: snapshots, schema change, and what you lose

Section 14.3 said replaying 200,000 events takes 235 ms. But that was replaying the whole system to build a read model โ€” a rare event. The more frequent job is reading the current state of one aggregate to process a command, and once an aggregate accumulates tens of thousands of events, folding it from scratch every time is unacceptable. A snapshot is the answer: store the state at version N ahead of time, then fold only the events after it.

Aggregate Fold from scratch Snapshot + tail Result
Long โ€” 100,000 events, snapshot at v90,000 7.398 ms 1.120 ms Faster by 6.6 times
Short โ€” 200 events, snapshot at v180 0.377 ms 0.981 ms Slower by 2.6 times

The second result is the more memorable one. For a short aggregate, a snapshot makes it slower โ€” because it adds one more round of querying (read the snapshot, then read the tail), and the cost of that round is larger than what it saves. A snapshot is not a mandatory step of event sourcing; it is a conditional optimization, and the condition is that the aggregate has to be long enough. The right approach is to measure the distribution of aggregate length in your own system and set a threshold, instead of turning on snapshots for everything.

๐Ÿ”ฌ A snapshot is a cache, and every rule of caching applies
A snapshot can be deleted at any time with no data loss โ€” rebuilding it from the log is fine. That means it is a cache, and everything from Lesson 5 applies without exception: it needs an invalidation strategy (change the folding rules and every old snapshot becomes garbage), it can drift (a snapshot written from a buggy version of the code carries that bug into every subsequent read), and it costs storage.

The most important practical consequence: when you fix the event-folding logic, you must delete every snapshot at the same time you deploy. Skip this step and the system will mix state computed under the old rules (from the snapshot) with events folded under the new rules (the tail) โ€” a kind of error that is very hard to spot because it only shows up on aggregates that already have a snapshot.

The second operational issue is that event schemas change over time. Last year's event had an amount field in dong; this year you add currency. The log is immutable so you cannot edit the old events โ€” instead, the reading code has to understand both formats, usually through an upcasting layer: read an old-version event and lift it into the new shape right at load time. That layer only ever grows over the years and can never be deleted โ€” it is a real debt, and it needs to be counted into the cost from the moment you decide.

โš ๏ธ Pitfall: losing the ability to query freely
With a state table, the question "which accounts have a negative balance" is one WHERE balance < 0. With a plain event log, that question has no direct answer โ€” the balance does not exist anywhere until someone folds the log. You can only answer questions you have already built a projection for.

For whoever operates the system, this is genuinely painful: every question that comes up mid-incident turns into "write a new projection, then wait for the replay". A common way to reduce the pain is to always keep one flat "current state" read model around, close enough to the relational model to query freely โ€” but then you are paying the cost of both models at once, and that is a trade-off worth stating up front, not discovering afterward.

Reproduce the measurements yourself

reproduce_measurements.sh
cd blog/sysdesign/sysdesign-lab

# --- Write 200,000 events into the append-only log ---
./tools/eventstore-test.sh seed 200000     # 1802ms ยท 111,003 events/s

# --- Run the projection AGAIN: idempotency decides everything ---
./tools/eventstore-test.sh replay2x 0      # naive:       run 2 -> 9,132,892 (DOUBLED)
./tools/eventstore-test.sh replay2x 1      # idempotent:  run 2 -> 0 events, 1ms

# --- Replay in application code: a more honest number than one GROUP BY ---
./tools/eventstore-test.sh projectApp      # 200k events / 235ms (174ms is pulling data)

# --- Snapshot only pays off for a LONG aggregate ---
./tools/eventstore-test.sh snapshot        # 100k events: 7.398ms -> 1.120ms  (6.6x)
./tools/eventstore-test.sh snapshot acc-0  # 200 events:  0.377ms -> 0.981ms  (SLOWER)

# --- Two people writing version 2 at once: UNIQUE(aggregate, version) blocks the lost update ---
./tools/eventstore-test.sh concurrent      # A wins ยท B gets a duplicate-key error

In summary

Event sourcing changes the question "what is the current state" into "what happened". That transformation is one-way: from the log you can always compute the state, from the state you cannot reconstruct the log. All the value โ€” full audit trail, rewinding to debug, answering questions nobody thought to ask at design time โ€” sits in the direction that gets lost.

The price is real too and needs saying in full: every engineer who touches that part has to learn one more model; the upcasting layer only ever grows over the years; and ad-hoc querying โ€” the cheapest thing with a relational table โ€” turns into "write a new projection, then wait for the replay". That is why event sourcing usually only earns its keep for a handful of core aggregates, not the whole system.

On operations, two numbers are worth carrying with you. Replaying 200,000 events in application code takes 235 ms โ€” fast enough that "fix the bug and rebuild the read model" is a business-hours task, and that is this model's real superpower. But it is only true when the projection is idempotent: the naive version, run a second time, produces a balance exactly 2.00 times too high, successfully, faster than the first run, with no warning at all.

And a snapshot is not a mandatory step but a conditional optimization: 6.6 times faster with a 100,000-event aggregate, but slower by 2.6 times with a 200-event aggregate. It is a cache, so every rule of caching from Lesson 5 applies without exception โ€” including the most annoying one: change the logic and you must wipe every snapshot at the same time you deploy.

Lesson 15 steps back to look at the whole picture: after paying for network hops, distributed locks, idempotency, and eventual consistency yourself, you finally have enough evidence to weigh the question every team runs into โ€” whether to split a system into microservices, and what that split actually costs. This time the cost will be measured, not guessed at.

๐Ÿ“– References

Download the lab source

The full event store used in the lab: an append-only log, two kinds of projection (idempotent and not), snapshots, and the two-writers-same-version measurement mode โ€” every number in this lesson comes from this file:

Download eventstore.js (event log, projection, snapshot โ€” 0 dependencies)

Related lessons in this series

Lesson 13: Rate Limiting & Backpressure Lesson 15: Monolith vs Microservices Back to the System Design roadmap

Comments