Lesson 18 / 18
Eventual Consistency and the User Experience
The lag window's counterpart in the interface: the stale views, wait rounds, conflict notifications, and silent losses four client strategies produce over the same event sequence, the optimistic display producing silent loss once the lag exceeds the view count, and read-your-writes raising the write store from 97.22 to 137.22 ops/s.
Contents
The previous three lessons treated the lag window as a resource measure: how many events, how many hours, how many gigabytes. What sits inside that window is not a number, it is a user. A recipient corrects an address and reloads the page to see the old one; two parties change the same shipment at once, and one never learns the other’s change was lost.
The term this course uses is eventual consistency, defined in the Introduction to System Design course: once writes stop, replicas converge to the same value, with silence about what reads see until then. Monotonic reads and read-your-writes were also defined there and are not repeated here. This lesson’s question is how that silence gets filled in the interface, and what each fill adds to K01’s computed value.
The Source of the Measurement
No browser output is produced for the interface measurement; what the user sees is drawn from a state machine. The user writes in one round and looks several times in later rounds; the projection applies writes at a fixed lag, and a round is an abstract step, not a second. In one in four sessions another party also writes to the same shipment; in the log, whichever comes last wins.
The last one is the real measure. Silent loss is the user’s write invalidated by another write, with the user never learning it. An interface decision’s quality is measured by this number more than by the count of stale views.
// interface/session.mjs — in-process model of a user session. No browser, screen, or duration; // a round is an abstract step, and what the user sees comes out of the projection's lag window. // Four strategies see the same event sequence with the same lag; only the client's behavior differs. export function run({ strategy, lag, views, sessions }) { const writes = []; for (let i = 0; i < sessions; i += 1) { const t = i + 1, id = `G${i + 1}`; writes.push({ round: t, id, value: `k${i}`, writer: "user" }); if (i % 4 === 0) writes.push({ round: t, id, value: `r${i}`, writer: "rival" }); } const applied = (t) => { // projection: writes with round <= t - lag are applied const m = new Map(); for (const w of writes) if (w.round <= t - lag) m.set(w.id, w); return m; }; const fromLog = (id) => { // read from the write side: every write is visible let last = null; for (const w of writes) if (w.id === id) last = w; return last; }; const s = { stale: 0, wait: 0, requests: 0, writeSide: 0, notified: 0, silentLoss: 0, correctRound: 0 }; for (let i = 0; i < sessions; i += 1) { const t0 = i + 1, id = `G${i + 1}`; const winner = fromLog(id); const beaten = winner.writer !== "user"; let correctRound = null, notified = false; if (strategy === "raw") { for (let k = 1; k <= views; k += 1) { s.requests += 1; const m = applied(t0 + k); if (m.has(id) === false) { s.stale += 1; continue; } if (correctRound === null) correctRound = k; } } else if (strategy === "optimistic") { for (let k = 1; k <= views; k += 1) { s.requests += 1; // the client keeps its own write on screen: no stale value const m = applied(t0 + k); if (m.has(id) === false) continue; if (correctRound === null) correctRound = k; if (beaten && notified === false) { s.notified += 1; notified = true; } } } else if (strategy === "wait") { for (let k = 1; k <= views + lag; k += 1) { s.requests += 1; // the write returns a version token, the client polls const m = applied(t0 + k); if (m.has(id) === false) { s.wait += 1; continue; } correctRound = k; if (beaten) { s.notified += 1; notified = true; } break; } s.requests += views - 1; // normal views after the wait ends } else { for (let k = 1; k <= views; k += 1) { s.requests += 1; if (k <= lag) { // inside its own write window, read from the write side s.writeSide += 1; if (correctRound === null) correctRound = k; if (beaten && notified === false) { s.notified += 1; notified = true; } continue; } const m = applied(t0 + k); if (m.has(id) && correctRound === null) correctRound = k; } } if (beaten && notified === false) s.silentLoss += 1; s.correctRound += correctRound ?? views; } return { ...s, correctRound: s.correctRound / sessions }; }
Four strategies diverge. Raw reads show the projection as it is. Optimistic display is the client keeping its own write on screen — its source is the client, not the server. Waiting for a token polls until it reaches the version token the write returned. Read-your-writes sends reads to the write side instead of the projection throughout the lag window; the response there is produced by folding the log and costs 8 operations, as measured previously.
// interface/measure.mjs — four strategies are compared at the same lag, then lag is swept and // the results are applied to K01's read rate. All numbers are deterministic; a computed value. import { run } from "./session.mjs"; const SESSIONS = 200, VIEWS = 4, LAG = 3; const STRATEGIES = ["raw", "optimistic", "wait", "read-your-writes"]; const b = (x, n = 2) => x.toFixed(n); const p = (x, n) => String(x).padStart(n); console.log(`${SESSIONS} sessions, ${VIEWS} views per session, projection lag ${LAG} rounds` + `, a rival write hits the same shipment in one in four sessions`); console.log(`\n${"strategy".padEnd(16)}${"stale views".padStart(13)}${"wait rounds".padStart(14)}` + `${"read requests".padStart(14)}${"write side".padStart(14)}${"notification".padStart(14)}` + `${"silent loss".padStart(14)}${"correct round".padStart(15)}`); const baseline = {}; for (const st of STRATEGIES) { const r = run({ strategy: st, lag: LAG, views: VIEWS, sessions: SESSIONS }); baseline[st] = r; console.log(`${st.padEnd(16)}${p(r.stale, 13)}${p(r.wait, 14)}${p(r.requests, 14)}` + `${p(r.writeSide, 14)}${p(r.notified, 14)}${p(r.silentLoss, 14)}${b(r.correctRound).padStart(15)}`); } console.log(`\n${"lag".padStart(8)}${STRATEGIES.map((s) => `${s} stale`.padStart(24)).join("")}`); for (const g of [1, 3, 6, 12]) { const row = STRATEGIES.map((s) => { const r = run({ strategy: s, lag: g, views: VIEWS, sessions: SESSIONS }); return `${r.stale}/${r.silentLoss}`.padStart(24); }); console.log(`${p(g, 8)}${row.join("")}`); } console.log("(columns: stale views / silent loss)"); // Back to K01 const READ = 41.67, WRITE_OPS = 97.22, OY5 = 0.04, LOG_FOLD = 8; console.log(`\nK01: reads behind cache ${READ}/s; write store ${WRITE_OPS} ops/s (previous lesson)`); console.log(`OY5 = ${OY5} -> reads in the write window ${b(READ * OY5)}/s\n`); console.log(`${"strategy".padEnd(16)}${"reads/session".padStart(15)}${"extra reads/s".padStart(15)}` + `${"read store/s".padStart(15)}${"requests to store/s".padStart(21)}${"write store ops/s".padStart(19)}`); for (const st of STRATEGIES) { const r = baseline[st]; const perSession = r.requests / SESSIONS; const window = READ * OY5; const extra = window * (perSession - VIEWS); const shifted = window * (r.writeSide / SESSIONS); const readStore = READ + extra - shifted; console.log(`${st.padEnd(16)}${b(perSession).padStart(15)}${b(extra).padStart(15)}` + `${b(readStore).padStart(15)}${b(readStore + shifted + 97.22).padStart(21)}` + `${b(WRITE_OPS + shifted * LOG_FOLD).padStart(19)}`); } for (const oy of [OY5, 2 * OY5]) { const window = READ * oy; const wait = window * (baseline.wait.requests / SESSIONS - VIEWS); const ryw = window * (baseline["read-your-writes"].writeSide / SESSIONS) * LOG_FOLD; console.log(`OY5 = ${b(oy)} -> wait's extra reads ${b(wait)}/s (${b((100 * wait) / READ)}%)` + `, read-your-writes to write store ${b(ryw)} ops/s (${b((100 * ryw) / WRITE_OPS)}%)`); }
200 sessions, 4 views per session, projection lag 3 rounds, a rival write hits the same shipment in one in four sessions
strategy stale views wait rounds read requests write side notification silent loss correct round
raw 400 0 800 0 0 50 3.00
optimistic 0 0 800 0 50 0 3.00
wait 0 400 1200 0 50 0 3.00
read-your-writes 0 0 800 600 50 0 1.00
lag raw stale optimistic stale wait stale read-your-writes stale
1 0/50 0/0 0/0 0/0
3 400/50 0/0 0/0 0/0
6 800/50 0/50 0/0 0/0
12 800/50 0/50 0/0 0/0
(columns: stale views / silent loss)
K01: reads behind cache 41.67/s; write store 97.22 ops/s (previous lesson)
OY5 = 0.04 -> reads in the write window 1.67/s
strategy reads/session extra reads/s read store/s requests to store/s write store ops/s
raw 4.00 0.00 41.67 138.89 97.22
optimistic 4.00 0.00 41.67 138.89 97.22
wait 6.00 3.33 45.00 142.22 97.22
read-your-writes 4.00 0.00 36.67 138.89 137.22
OY5 = 0.04 -> wait's extra reads 3.33/s (8.00%), read-your-writes to write store 40.00 ops/s (41.15%)
OY5 = 0.08 -> wait's extra reads 6.67/s (16.00%), read-your-writes to write store 80.01 ops/s (82.29%)
Reading the Numbers
The first table compares the four strategies at the same window. Raw reads show the value from before the user’s write in 400 of 800 views, and in 50 sessions the write silently vanishes — the user never sees their own value, so the system has nothing to tell them either. This is the cheapest and most damaging option.
Optimistic display brings stale views to zero and reports all 50 conflicts, with no extra requests. But its correctness depends on a condition the second table shows: at a lag of 6 and 12 rounds, silent loss returns to 50. The reason is arithmetic — the user looks four times, the projection refreshes six rounds later, and the comparison never happens. Optimistic display goes silent once the lag window exceeds the user’s view count.
Waiting for a token keeps stale views and silent loss at zero but pays 400 wait rounds and 1,200 read requests: 6 per session instead of 4, 50 percent more. Read-your-writes also keeps both at zero and shows the correct value on the first round (3.00 for the other three), at the cost of 600 reads falling to the write side.
The lag sweep ranks the strategies. At a lag of 1, all four are clean; as it grows, raw reads breaks first, then optimistic display. Wait and read-your-writes stay clean at every lag, because both close the window by waiting or by skipping it.
Back to the Computed Value
Tying the numbers to K01 needs a ratio.
OY5 — the share of tracking reads made in the lag window after the user’s own write: 0.04. This is this topic’s own assumption and is not added to K01’s table. Its rationale is that most state events come from carrier integration, and the only interface writers are the recipient correcting an address and the seller updating a shipment. Its sensitivity is given at 0.08.
The last table shows what the four strategies do to K01’s rows. Raw reads and optimistic display
move no number: requests reaching the store/s stays at 138.89. Waiting for a token raises it to
142.22 with polling requests, an 8 percent increase landing on the read store. Read-your-writes
does not change the request count but shifts 5.00 requests/s from the read store to the write
store; at 8 operations each, the write store’s load climbs from 97.22 to 137.22 ops/s, a 41.15
percent increase. At double OY5 the increase is 82.29 percent.
The comparison fits one sentence: waiting charges the read store, read-your-writes charges the write store. Three lessons tried to protect the write store, and the strongest session guarantee lands right there. The decision is therefore made per shipment: a user correcting an address reads their own write, a user only tracking a shipment reads the projection.
Summary
- Eventual consistency’s measure in the interface is not stale views but silent loss: the user’s write invalidated and never learned about.
- Raw reads showed the old value in 400 of 800 views and produced 50 silent losses; optimistic display zeroed both and reported all 50 conflicts.
- Optimistic display’s condition is that lag stays below the view count: at a lag of 6 and 12 rounds, silent loss returned to 50.
- Waiting for a token pays 400 wait rounds and 6 requests per session instead of 4; read-your-writes shows the correct value on the first round (3.00 for the others) and sends 600 reads to the write side.
- Back to K01: waiting raises
requests reaching the store/sfrom 138.89 to 142.22; read-your-writes leaves the request count unchanged but raises the write store from 97.22 to 137.22 ops/s (41.15 percent). At OY5 = 0.08 the increase is 82.29 percent.
Course Wrap-Up
The course removed the single-store assumption and asked the same three questions across three topics: what is the decision, which number in K01’s computed value does it move, and which number grows in exchange.
| Lesson | Decision | Number it moves | What grows in exchange |
|---|---|---|---|
| Data Store Selection | splitting stores by access pattern | records processed on the read side 291.69 → 41.67 | store operation rate 138.89 → 236.11; ratio 2.33 → 4.67 |
| Replication | splitting reads across replicas | reads per replica 41.67 → 8.33 | stored data GB 718.24 → 4,309.44 (five replicas); ratio 14.00 |
| Federation | splitting data by function | scan rate 833.33 records/s → 0 (online store) | 976 → 1,232 MB/day, 712.48 → 899.36 GB |
| Sharding | choosing the key from the access pattern | 78.11 GB and 17.36 ops/s per node | touches in a non-filtering pattern 138.89 → 1,111.12/s |
| Partitioning Strategies | the rule converting a key to a partition | hot spot ratio 8.00 → 1.00; scanned records 12,000,000 → 1,500,137 | requests reaching the store/s 138.89 → 277.78 |
| Denormalization | shortening the read path | secondary records pulled 9,000 → 40 | ratio 2.33 → 8.71; 712.48 → 733.27 GB |
| Materialized Views | precomputing the result | scan 833.33 → 8.33 records/s, 10,800 → 3.28 MB | ratio 2.33 → 7.13 (at a one-minute refresh) |
| Store Types | choosing the type by access shape | a three-step graph traversal 3 → 1 request | key–value scan 1 → 3,001 requests; 138.89 → 236.11/s |
| Cache Placement | which layer the hit is served from | reads reaching the app 416.67 → 60.51/s | 9 touches per miss, average 2.531 touches/request |
| Cache-Aside | fetching responsibility sitting in the application | the 0.90 hit-ratio assumption comes out 0.7287 | requests reaching the store/s 138.89 → 210.26; ratio 2.33 → 0.86 |
| Write-Through and Write-Behind | wiring the write path to the cache | hit ratio 0.7285 → 0.9170; requests 229.01 → 151.04/s | loss window 200 events, 44,000 bytes |
| Refresh-Ahead | renewing before the ttl expires | requests held 57,815 → 34,270 | refreshes 165,730, wasted 27,825; requests 151.04 → 217.67/s |
| Cacheless Design | where a cache is not needed | requests reaching the store/s 513.89 → 139.90 |
2.00 wasted touches per request in the scan pattern |
| Consistency and Stale Data | accepting the staleness window | window 5 → 60 s; requests 217.67 → 140.23/s | stale ratio 0.001195, largest lag 1 event |
| Command and Query Separation | separating the read and write paths | query touches 8 → 1; nodes 17 → 7 (read ×4) | command 3 → 5 touches; nodes 15 → 21 at write ×4 |
| Event-Sourced Design | change becoming the source-of-truth record | operations per request 4.50 → 3.10 | 976 → 1,168 MB/day, 712.48 → 852.64 GB; scan 10.80 → 35.04 GB |
| Deriving Read Models | deriving the projection from the log | rebuild 2,336,000,000 → 16,000,000 events | 1,168 → 1,360 MB/day, 852.64 → 992.80 GB |
| Eventual Consistency and the User Experience | closing the window in the interface | silent loss 50 → 0; seeing the correct value 3.00 → 1.00 round | write store 97.22 → 137.22 ops/s |
The table’s rule deserves to be named. In this course, a data decision can only be defended together with an access pattern. The same decision is a gain under one pattern and a loss under another: a shard key is cheap under a filtering pattern, eight times more expensive under one that cannot filter; a cache gains on repeated reads, adds a touch on a scan; an event log gains on writes, loses 3.24 times on the periodic scan; read–write separation gains as reads grow, loses as writes grow. A decision given without its pattern cannot be measured, because which number moves is not settled. The course’s own assumptions — the ones Data Distribution and Cache Architecture wrote under their own names, and this topic’s OY1–OY5 — never mixed into K01’s table; each was written with its rationale and its sensitivity.
The question the course leaves behind sits in the shared assumption underneath all these parts. Data was replicated, split by function, sharded, cached, and the read model separated from the write model; every decision moved one number and grew another. But every measurement assumed the parts worked. What happens when a replica goes down, a shard slows, or the link between the log feeding a projection and the read side breaks was not designed in this course: partial failure, the spread of slowdown, network partition, circuit breaking, back pressure, and recovery targets were never counted. The next course, Resilience and Reliability, takes up that question.
To keep your progress and take notes, Log in
My notes
Log in to take notes.