---
title: 'Eventual Consistency and the User Experience'
source: 'https://academia.sh/en/courses/scaling-the-data/eventual-consistency-and-the-user-experience'
course: 'Scaling the Data Layer'
language: en
updated: '2026-08-23T07:01:34+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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/s` from 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.
