"I saved, and I still see the old name"
The user changes the profile name, presses "Save" and the page shows the old one; they reload and the new one appears. For the user it is a bug. For the system it is the expected behaviour: the write went to the primary, the read to a replica that has not yet received the change. Eventual consistency is a property you design with, not a defect to hide: it promises that the replicas, once writes stop, converge, and says nothing about when. Your job is to decide, data item by data item, what the user must see in the interval, and to choose the cheapest mechanism that guarantees it.
This article closes the series and uses everything you have seen. I simulate a primary with two lagging replicas to show three anomalies (a stale read, a read that goes back, the loss of a change), then the remedies: a version token to read your own writes, the optimistic interface with rollback, and conflict detection with vector clocks. The definitions of the models come from Jepsen, the facts about the services from the official guides consulted on 2026-11-12. The same themes, from the side of the service that responds, are in the article on CQRS, where the lagging view comes from the projection.
The models, without the folklore
Between "strong" and "eventual" there is a spectrum, and the useful guarantees sit in the middle. Jepsen defines them precisely, and three are enough here. Read your writes: if a process writes and then reads, it sees its own write; Jepsen specifies that you get it by always staying on the same server (it is "sticky available"), not by changing server.
Monotonic reads: a later read cannot observe a state earlier than that of a read already made, but only for the same process. Causal consistency: operations linked by cause and effect appear in the same order to everyone, while independent ones can appear in a different order. These are guarantees per session, and that is why they can be given without paying for strong consistency across the whole system.
Two commonplaces to avoid, verified against the guides. S3 is no longer "eventual": the guide says it provides strong read-after-write consistency for PUT and DELETE requests on objects, in all regions. DynamoDB instead offers two read options for tables and local indexes, eventually consistent (the default) and strongly consistent; reads from global indexes and from streams are always eventually consistent. Whoever lists services as "strong/eventual" from memory is wrong in one of the two directions, so the choice is made on the page of the version you use.
Three anomalies in one program
The primary assigns a version number to every write and propagates it to the replicas, which receive it with different delays: R1 after 2 seconds, R2 after 5. The client that writes receives the version and can carry it in later reads as a token. If the replica queried is behind the token, the read goes to the primary. It is the simplest mechanism that gives read-your-writes without forcing all traffic onto the primary.
// A primary and two replicas with propagation delay. Virtual time in seconds.
const clock = { t: 0 };
type Entry = { version: number; value: string };
class Replica {
name: string;
delay: number;
private data: Entry = { version: 0, value: 'Mario Rossi' };
private incoming: { when: number; entry: Entry }[] = [];
constructor(name: string, delay: number) { this.name = name; this.delay = delay; }
receive(entry: Entry): void { this.incoming.push({ when: clock.t + this.delay, entry }); }
read(): Entry {
for (const m of this.incoming) if (m.when <= clock.t && m.entry.version > this.data.version) this.data = m.entry;
return this.data;
}
}
const r1 = new Replica('R1', 2), r2 = new Replica('R2', 5);
let primary: Entry = { version: 0, value: 'Mario Rossi' };
let primaryReads = 0;
function write(value: string): number {
primary = { version: primary.version + 1, value };
[r1, r2].forEach((r) => r.receive(primary));
return primary.version; // the client receives the written version and keeps it
}
const show = (v: Entry) => `v${v.version} "${v.value}"`;
function readWithToken(r: Replica, minVersion: number): Entry {
const v = r.read();
if (v.version >= minVersion) return v;
primaryReads++; // the replica is behind what the client wrote
return primary;
}
// Write at t=0: R1 will receive it at t=2, R2 at t=5.
const mine = write('Mario Bianchi');
clock.t = 1;
console.log('t=1 A) from R1, no token :', show(r1.read()), '<- the old name, right after "Save"');
console.log('t=1 C) from R1, with token v' + mine + ' :', show(readWithToken(r1, mine)), '| reads on the primary:', primaryReads);
clock.t = 3;
const first = r1.read(), then = r2.read();
console.log('t=3 B) R1 first, then R2 :', show(first), '|', show(then), '<- the second read goes back');
console.log('t=3 C) from R2, with token v' + mine + ' :', show(readWithToken(r2, mine)), '| reads on the primary:', primaryReads);
clock.t = 6;
console.log('t=6 C) from R2, with token v' + mine + ' :', show(readWithToken(r2, mine)), '| reads on the primary:', primaryReads, '(the replica has reached the token)');
// Output:
// t=1 A) from R1, no token : v0 "Mario Rossi" <- the old name, right after "Save"
// t=1 C) from R1, with token v1 : v1 "Mario Bianchi" | reads on the primary: 1
// t=3 B) R1 first, then R2 : v1 "Mario Bianchi" | v0 "Mario Rossi" <- the second read goes back
// t=3 C) from R2, with token v1 : v1 "Mario Bianchi" | reads on the primary: 2
// t=6 C) from R2, with token v1 : v1 "Mario Bianchi" | reads on the primary: 2 (the replica has reached the token)
At t=1 the read from R1 without a token returns v0: the old name right after saving. With token
v1, the readWithToken function sees that R1 is at v0 and falls back on the primary (first read on the
primary). At t=3 row B shows the worst anomaly: R1 already has v1, R2 still v0, and a user who
reloads and lands on another replica sees the name go back.
It is a violation of monotonic reads, and the remedy
is to stay on the same replica for the session or to use the token. At t=6 R2 has reached v1 and the token
costs nothing any more: the load on the primary exists only during the lag interval. The cost of the token is exactly that: one more
field to carry around (cookie, header, client state) and a fallback that, if all the tokens are recent, concentrates the
reads on the primary. The durations are virtual seconds, not measurements of a database.
Optimistic interface: two layers and a rollback that is a subtraction
The optimistic interface shows the effect of the action immediately and reconciles later with the server's response. It goes wrong when you modify the displayed state directly: you no longer know what to undo if the response is a rejection or if it arrives out of order. The robust model has two layers, what the server has confirmed and the list of operations in flight, and the view is the sum.
A rollback then is not an operation: you remove the operation from the in-flight layer. The program shows it without any
framework; in React 19 the equivalent is useOptimistic, whose update function, says the React documentation, must be called inside an Action.
type Task = { id: string; done: boolean };
type Op = { opId: number; taskId: string; done: boolean };
// Two layers: what the server has confirmed and the operations still in flight. The interface shows their sum.
let confirmed: Task[] = [{ id: 'a', done: false }, { id: 'b', done: false }];
let inFlight: Op[] = [];
const warnings: string[] = [];
const view = (): string =>
confirmed.map((t) => {
const op = [...inFlight].reverse().find((o) => o.taskId === t.id); // the last in-flight operation wins in the view
return `${t.id}=${(op ? op.done : t.done) ? 'x' : '_'}${op ? '*' : ''}`;
}).join(' ');
function click(opId: number, taskId: string): void {
const current = confirmed.find((t) => t.id === taskId)!;
const last = [...inFlight].reverse().find((o) => o.taskId === taskId);
inFlight = [...inFlight, { opId, taskId, done: !(last ? last.done : current.done) }];
console.log(`click #${opId} on ${taskId} -> ${view()}`);
}
function reply(opId: number, ok: boolean): void {
const op = inFlight.find((o) => o.opId === opId)!;
inFlight = inFlight.filter((o) => o.opId !== opId); // the operation leaves the "in flight" layer
if (ok) confirmed = confirmed.map((t) => (t.id === op.taskId ? { ...t, done: op.done } : t));
else warnings.push(`#${opId} rejected: ${op.taskId} goes back as it was`); // rollback = simply not applying it
console.log(`reply #${opId} ${ok ? 'ok ' : 'reject'} -> ${view()}`);
}
click(1, 'a');
click(2, 'b');
click(3, 'a'); // the user changes their mind about "a" before the server replies
reply(2, false); // the server rejects #2: "b" goes back as before
reply(1, true); // #1 confirms "a=x", but #3 is still in flight: the interface shows #3
reply(3, true);
console.log('warnings:', warnings);
// Output:
// click #1 on a -> a=x* b=_
// click #2 on b -> a=x* b=x*
// click #3 on a -> a=_* b=x*
// reply #2 reject -> a=_* b=_
// reply #1 ok -> a=_* b=_
// reply #3 ok -> a=_ b=_
// warnings: [ '#2 rejected: b goes back as it was' ]
The * sign marks a change not yet confirmed. The user clicks on a (checks it), on b
(checks it) and again on a (unchecks it) before any response. The rejection of #2 makes b go back
to _ and a warning fires. Then #1 is confirmed, but the view does not change: #3 is still in flight and wins, so
a does not light up again for a moment.
This is the typical defect of a naively written optimistic interface, the flicker when responses arrive in a different order from the clicks, and it is the reason for the second layer. For critical operations (a payment) it is not used: the user waits for the confirmation.
When two people write together
With several writable replicas, or with several users on the same document, two changes can be concurrent: neither has seen the other. A vector clock keeps one counter per node and lets you say so: if all of A's counters are greater than or equal to B's, A follows B; if some are greater and others smaller, they are concurrent. The timestamp, instead, always orders, even when no order exists, and it is the reason why last-write-wins loses data without warning. The program tries both ways, a three-way merge per field and a counter that converges.
type Clock = Record<string, number>;
type Relation = 'equal' | 'precedes' | 'follows' | 'concurrent';
const increment = (c: Clock, node: string): Clock => ({ ...c, [node]: (c[node] ?? 0) + 1 });
function compare(a: Clock, b: Clock): Relation {
let aGreater = false, bGreater = false;
for (const n of new Set([...Object.keys(a), ...Object.keys(b)])) {
if ((a[n] ?? 0) > (b[n] ?? 0)) aGreater = true;
if ((b[n] ?? 0) > (a[n] ?? 0)) bGreater = true;
}
return aGreater && bGreater ? 'concurrent' : aGreater ? 'follows' : bGreater ? 'precedes' : 'equal';
}
type Doc = { title: string; description: string; clock: Clock; when: number };
// Common initial state, then two independent changes on two nodes.
const base: Doc = { title: 'Draft', description: 'Text', clock: { A: 1 }, when: 10 };
const onA: Doc = { ...base, title: 'Plan 2027', clock: increment(base.clock, 'A'), when: 20 };
const onB: Doc = { ...base, description: 'Revised text', clock: increment(base.clock, 'B'), when: 30 };
console.log('A against base :', compare(onA.clock, base.clock));
console.log('A against B :', compare(onA.clock, onB.clock));
// Last-write-wins: the highest timestamp wins, the other change vanishes without a warning.
const lww = onA.when > onB.when ? onA : onB;
console.log('LWW :', lww.title, '|', lww.description, '<- the new title is lost');
// Three-way merge per field: if only one side changed a field against the ancestor, accept it; if both, conflict.
function merge3(ancestor: Doc, x: Doc, y: Doc): { doc: Doc; conflicts: string[] } {
const conflicts: string[] = [];
const pick = (field: 'title' | 'description'): string => {
const cx = x[field] !== ancestor[field], cy = y[field] !== ancestor[field];
if (cx && cy && x[field] !== y[field]) { conflicts.push(field); return x[field]; }
return cx ? x[field] : y[field];
};
const doc: Doc = { title: pick('title'), description: pick('description'), clock: {}, when: Math.max(x.when, y.when) };
for (const n of new Set([...Object.keys(x.clock), ...Object.keys(y.clock)])) doc.clock[n] = Math.max(x.clock[n] ?? 0, y.clock[n] ?? 0);
return { doc: { ...doc, clock: increment(doc.clock, 'merge') }, conflicts };
}
const m = merge3(base, onA, onB);
console.log('three-way merge :', m.doc.title, '|', m.doc.description, '| conflicts:', m.conflicts);
const onB2: Doc = { ...base, title: 'Schedule', clock: increment(base.clock, 'B'), when: 30 };
console.log('same field :', JSON.stringify(merge3(base, onA, onB2).conflicts), '<- the user must be asked');
// Converging counter: each node counts its own increments, the merge takes the maximum per node.
type GCounter = Record<string, number>;
const mergeG = (a: GCounter, b: GCounter): GCounter => {
const r = { ...a };
for (const [n, v] of Object.entries(b)) r[n] = Math.max(r[n] ?? 0, v);
return r;
};
const value = (c: GCounter) => Object.values(c).reduce((s, v) => s + v, 0);
const nodeA: GCounter = { A: 3 }, nodeB: GCounter = { B: 2 }; // 3 likes on A, 2 on B, independent
console.log('sum of the merges :', value(mergeG(nodeA, nodeB)), '| in reverse order:', value(mergeG(nodeB, nodeA)), '| repeated:', value(mergeG(mergeG(nodeA, nodeB), nodeB)));
console.log('"last value" :', Math.max(value(nodeA), value(nodeB)), '<- loses 2 likes');
// Output:
// A against base : follows
// A against B : concurrent
// LWW : Draft | Revised text <- the new title is lost
// three-way merge : Plan 2027 | Revised text | conflicts: []
// same field : ["title"] <- the user must be asked
// sum of the merges : 5 | in reverse order: 5 | repeated: 5
// "last value" : 3 <- loses 2 likes
The first two lines are the base: A follows the base, but A and B are concurrent. Last-write-wins picks B (timestamp
30 against 20) and the title "Plan 2027" vanishes: that change was never in conflict with B, they are different fields. The three-way
merge compares each field with the common ancestor and keeps both, with no conflicts. When both touch the same
field (onB2 changes the title) the conflict goes to the user: no algorithm knows which title is right.
The grow-only counter (G-Counter) shows a third way: if each node counts its own increments and the merge takes the maximum per node, the result is 5 in any order and even when repeating the merge, whereas "keep the highest value" would lose two likes. Structures with this property are called CRDTs. They are not the answer to everything: they work for chosen data types.
What to tell the user
The interface patterns most used to handle the interval are these:
- an update indicator ("updated 2 minutes ago"), for data that can be stale;
- a visible saving state, for operations in flight;
- a confirmation after the server's response, not before, for the operations that matter;
- a silent retry for transient errors, with a message only if all of them fail;
- deletion shown immediately but executed after confirmation, for what cannot be restored.
They are five answers to the same question: does the user know what state their data is in? The same idea exists at the HTTP level:
RFC 5861 defines the stale-if-error and stale-while-revalidate extensions of Cache-Control,
which let a cache serve a stale response while it refreshes it. The update indicator is the part
that falls to you: if you serve stale data, say so.
Different data, different guarantees
Not the whole system needs the same guarantee, and it is worth writing the list. A balance, seat availability, a price at the moment of purchase require reads from the authoritative data, or at least a version token. A profile, a feed, a counter of likes, a statistic tolerate a delay of seconds, as long as the user does not see what they have just written go back. The decision is made with a single question: what is the harm if this user now sees data a few seconds old? If the harm is an irritation, read-your-writes on their own writes is enough; if it is a wrong charge, you need the authoritative read.
What you can do now
You can name the intermediate guarantees (read your writes, monotonic reads, causal) and you know what each one costs. You can carry a version token in reads, build an optimistic interface with rollback and no flicker, and tell a real conflict from two independent changes.
With this article the series has covered domain events, event sourcing, CQRS and its combination with events, the saga, the messaging services (EventBridge and SQS and SNS), the dead letter queue, idempotency and the outbox. The sensible reading order for anyone starting from zero is that of the series: each piece presupposes the previous ones.
Sources
- Jepsen, Read Your Writes - definition and "sticky available" property, consulted on 2026-11-12
- Jepsen, Monotonic Reads - definition and limit to reads by the same process, consulted on 2026-11-12; Causal Consistency - order of causally linked operations
- Amazon S3 User Guide, What is Amazon S3? - strong read-after-write consistency for PUT and DELETE, consulted on 2026-11-12
- Amazon DynamoDB Developer Guide, Read consistency - eventually consistent (default) and strongly consistent reads, consulted on 2026-11-12
- React, useOptimistic - the update function must be called inside an Action, consulted on 2026-11-12
- RFC 5861, HTTP Cache-Control Extensions for Stale Content -
stale-if-errorandstale-while-revalidate, consulted on 2026-11-12
The three programs (replicas.ts, optimistic.ts, conflicts.ts) were run with Node.js v24.11.0; the outputs are real for the models, and the times are virtual seconds, not measurements. The behaviour of useOptimistic in React 19 is reported from the React documentation and was not run here.
Commenti
Caricamento commenti...
Sign in to comment