Eventual Consistency: Strategies, Trade-Offs and UX Patterns
A user updates their profile, clicks "Save" and still sees their old name. Reload the page: now you see the new name. In the middle: an event-driven system stood propagating the update between services. This is theeventual consistency in action — and the wrong answer is the one the user thinks the system has a bug. The right answer, that of the architect, is to design the system so that this window of inconsistency is invisible or acceptable.
Possible consistency is not a compromise to be hidden: it is an architectural choice aware with real benefits (availability, scalability, resilience) and real challenges (complexity, testing, UX). Understand consistency patterns and strategies for managing Temporary inconsistencies are essential to building distributed systems that work really in production.
What You Will Learn
- CAP Theorem and BASE vs ACID: the theoretical framework
- Consistency patterns: strong to eventual
- Read-Your-Writes: the strategy to not show old data immediately after a write
- Optimistic UI: Updates the interface before the server responds
- Conflict detection and reconciliation for concurrently modified data
- Versioning with vector clocks to detect divergences
- How to communicate the possible consistency to the user without confusing him
CAP Theorem and BASE: The Theoretical Framework
Il CAP Theorem states that in a distributed system with partitions network (P inevitable in production), you can have either Consistency (all nodes see the same data) o Availability (the system always responds), but not both at the same time during a partition.
| Property | ACID (relational DB) | BASIC (distributed systems) |
|---|---|---|
| Consistency | Strong: Every read sees the last write | Eventual: Nodes converge over time |
| Availability | Not guaranteed during partitions | High: The system always responds |
| Stay | Atomic: all or nothing | Soft state: The state can change without input |
| Examples | PostgreSQL, MySQL, Oracle | DynamoDB, Cassandra, CouchDB |
Consistency Models: The Spectrum
Between strong consistency and eventual consistency there are many intermediate models. Knowing them allows you to choose the right trade-off for each use case:
// Spettro dei modelli di consistenza (dal piu forte al piu debole)
// 1. STRONG (Linearizable) Consistency
// Ogni operazione sembra atomica e globalmente ordinata
// Esempio: PostgreSQL con una singola istanza
// Trade-off: latenza alta, disponibilita ridotta
// 2. SEQUENTIAL Consistency
// Le operazioni appaiono nell'ordine in cui vengono eseguite
// ma non necessariamente in tempo reale
// Esempio: spanner.google.com (TrueTime)
// 3. CAUSAL Consistency
// Le operazioni causalmente correlate sono viste nell'ordine corretto
// Le operazioni non correlate possono essere viste in ordine diverso
// Esempio: MongoDB con causally consistent sessions
// 4. READ-YOUR-WRITES (Session Consistency)
// Un client vede sempre le sue ultime scritture
// Altri client potrebbero vedere dati vecchi
// Esempio: DynamoDB con sticky sessions
// 5. MONOTONIC READ Consistency
// Una volta letto un valore, non vedrai mai un valore piu vecchio
// Non garantisce di vedere le proprie ultime scritture
// 6. EVENTUAL Consistency
// I nodi convergono allo stesso stato "eventualmente"
// Nessuna garanzia su quando o nell'ordine delle operazioni
// Esempio: DNS, DynamoDB default, AWS S3
Read-Your-Writes: Don't Show Old Data Immediately After Writing
The most frequent problem with eventual consistency in production: the user updates something, gets redirected to the detail page, and sees again the old value because the replica has not yet propagated the change. Read-Your-Writes consistency ensures that a client sees always your latest writings.
// Strategia 1: Sticky routing — leggi sempre dallo stesso nodo
// Il client viene indirizzato sempre alla replica primaria per
// un periodo dopo la scrittura (es. 1-5 secondi)
// Implementazione con un token di versione
interface WriteResult {
entityId: string;
version: number; // numero di versione dopo la scrittura
timestamp: number; // timestamp della scrittura
}
// Il client salva il WriteResult e lo invia nelle successive letture
interface ReadRequest {
entityId: string;
minVersion?: number; // "voglio almeno questa versione"
}
// Il server: attendi che la replica raggiunga la versione richiesta
class ConsistentReadService {
async readWithVersion(
entityId: string,
minVersion?: number,
timeoutMs = 5000
): Promise<Entity> {
const startTime = Date.now();
while (true) {
const entity = await this.replica.findById(entityId);
if (!minVersion || entity.version >= minVersion) {
return entity;
}
if (Date.now() - startTime > timeoutMs) {
// Timeout: leggi dal primario come fallback
return await this.primary.findById(entityId);
}
await this.sleep(50); // Breve attesa prima del retry
}
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
// Strategia 2: Redirect al primario per N secondi dopo scrittura
// Il CDN/LB indirizza le letture del client alla primary replica
// per 2-3 secondi dopo una scrittura
// Strategia 3: Cache invalidation
// Dopo la scrittura, invalida la cache del client
// La prossima lettura va direttamente alla primary
Optimistic UI: Update First, Reconcile Later
L'Optimistic UI and the fundamental UX pattern for systems to eventual consistency: update the interface immediately (assuming that the write will be successful), then reconciles with the server. Makes the system perceived as instantaneous even with high network latency.
// Optimistic UI con React e reconciliazione
import { useState, useOptimistic } from 'react';
interface Todo {
id: string;
text: string;
completed: boolean;
synced: boolean; // false = in attesa di conferma server
}
function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [todos, setTodos] = useState(initialTodos);
// useOptimistic: hook React 19 per Optimistic UI
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state: Todo[], newTodo: Todo) => [...state, newTodo]
);
async function handleToggle(todoId: string): Promise<void> {
const todo = todos.find((t) => t.id === todoId);
if (!todo) return;
const optimisticUpdate = { ...todo, completed: !todo.completed, synced: false };
// 1. Aggiorna UI immediatamente (ottimistico)
addOptimisticTodo(optimisticUpdate);
try {
// 2. Invia al server
const confirmed = await api.toggleTodo(todoId);
// 3. Sostituisci con il dato confermato dal server
setTodos((prev) =>
prev.map((t) => (t.id === todoId ? { ...confirmed, synced: true } : t))
);
} catch (error) {
// 4. ROLLBACK: ripristina lo stato originale se la scrittura fallisce
setTodos((prev) => prev.map((t) => (t.id === todoId ? todo : t)));
// Mostra feedback all'utente
showErrorToast('Impossibile aggiornare il task. Riprova.');
}
}
return (
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id} className={todo.synced ? '' : 'pending'}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
/>
{todo.text}
{!todo.synced && <span className="sync-indicator">Salvataggio...</span>}
</li>
))}
</ul>
);
}
Conflict Detection with Vector Clocks
When two users modify the same data concurrently on systems with multiple replication, a conflict is created. THE Vector Clocks allow you to automatically detect divergences and decide what to do an automatic merge or present the conflict to the user.
// Vector Clock: implementazione base in TypeScript
type VectorClock = Record<string, number>;
function increment(clock: VectorClock, nodeId: string): VectorClock {
return { ...clock, [nodeId]: (clock[nodeId] ?? 0) + 1 };
}
function merge(a: VectorClock, b: VectorClock): VectorClock {
const result: VectorClock = { ...a };
for (const [node, time] of Object.entries(b)) {
result[node] = Math.max(result[node] ?? 0, time);
}
return result;
}
type CompareResult = 'before' | 'after' | 'concurrent' | 'equal';
function compare(a: VectorClock, b: VectorClock): CompareResult {
const allNodes = new Set([...Object.keys(a), ...Object.keys(b)]);
let aGreater = false;
let bGreater = false;
for (const node of allNodes) {
const aTime = a[node] ?? 0;
const bTime = b[node] ?? 0;
if (aTime > bTime) aGreater = true;
if (bTime > aTime) bGreater = true;
}
if (aGreater && bGreater) return 'concurrent'; // conflitto
if (aGreater) return 'after';
if (bGreater) return 'before';
return 'equal';
}
// Utilizzo per rilevare conflitti di scrittura concorrente
interface Document {
id: string;
content: string;
vectorClock: VectorClock;
lastModifiedBy: string;
}
async function updateDocument(
docId: string,
newContent: string,
clientClock: VectorClock,
nodeId: string
): Promise<{ success: boolean; conflict?: { local: Document; remote: Document } }> {
const current = await db.findById(docId);
const comparison = compare(clientClock, current.vectorClock);
if (comparison === 'concurrent') {
// Conflitto! Entrambe le versioni hanno avanzato indipendentemente
return {
success: false,
conflict: {
local: { ...current, content: newContent, vectorClock: clientClock },
remote: current,
},
};
}
if (comparison === 'before') {
// Il client ha una versione stale: rifiuta e richiedi re-fetch
throw new Error('Stale write: fetch the latest version first');
}
// OK: scrivi con clock aggiornato
const newClock = increment(merge(clientClock, current.vectorClock), nodeId);
await db.update(docId, newContent, newClock, nodeId);
return { success: true };
}
Reconciliation Strategies
When you detect a conflict, you have several reconciliation strategies available, each appropriate for different data types:
// Strategie di reconciliazione dei conflitti
// 1. Last-Write-Wins (LWW)
// Il timestamp piu recente vince. Semplice ma perde dati.
// Usato da: AWS DynamoDB (default), Apache Cassandra
function resolveWithLWW(a: Document, b: Document): Document {
return a.updatedAt > b.updatedAt ? a : b;
}
// 2. Merge automatico per strutture dati CRDT-friendly
// Counter: somma i delta (non i valori assoluti)
interface Counter {
nodeIncrements: Record<string, number>; // per ogni nodo: totale incrementi
}
function mergeCounters(a: Counter, b: Counter): Counter {
const result: Record<string, number> = { ...a.nodeIncrements };
for (const [node, count] of Object.entries(b.nodeIncrements)) {
result[node] = Math.max(result[node] ?? 0, count);
}
return { nodeIncrements: result };
}
function getCounterValue(counter: Counter): number {
return Object.values(counter.nodeIncrements).reduce((sum, v) => sum + v, 0);
}
// 3. Three-way merge (come Git)
// Confronta le due versioni divergenti con il loro antenato comune
function threeWayMerge(
ancestor: string,
versionA: string,
versionB: string
): { merged: string; hasConflicts: boolean } {
// Usa diff3 o similar per testi
// Per strutture dati: merge field-by-field
// Se un campo e stato modificato in A ma non in B: accetta la modifica di A
// Se un campo e stato modificato in entrambi: conflitto da risolvere manualmente
// Implementazione completa dipende dal tipo di dato
return { merged: versionA, hasConflicts: true }; // placeholder
}
// 4. User-driven conflict resolution
// Presenta entrambe le versioni all'utente e lascia scegliere
async function presentConflictToUser(
conflict: { local: Document; remote: Document }
): Promise<Document> {
const resolution = await showConflictDialog({
localVersion: conflict.local,
remoteVersion: conflict.remote,
message: 'Il documento e stato modificato da un altro utente. Quale versione vuoi mantenere?',
});
return resolution.chosen === 'local' ? conflict.local : conflict.remote;
}
UX Patterns for Eventual Consistency
The most underestimated part of eventual consistency is communication with the user. Users do not understand (nor should they understand) consistency models, but they perceive immediately when something doesn't work as they expect.
Recommended UX Patterns
- Stale Indicators: shows "Updated 2 minutes ago" instead of hiding latency. Users understand that the data may not be the freshest.
- Pending State Visual: use spinners, gray indicators, or "Saving..." text to show when an operation is in progress.
- Success Confirmation: shows a confirmation toast/banner only after server confirmation, not optimistically for critical operations.
- Automatic Retry: for non-critical operations, do silent retry. Show the error only if all retries fail.
- Soft Delete before Hard Delete: show the item as deleted immediately, but perform the actual deletion only after server confirmation.
// Pattern: Stale-While-Revalidate per dati non critici
async function useStaleWhileRevalidate<T>(
key: string,
fetcher: () => Promise<T>
): Promise<{ data: T; isStale: boolean }> {
// 1. Servi immediatamente i dati in cache (potenzialmente stale)
const cached = await cache.get<{ data: T; timestamp: number }>(key);
if (cached) {
const isStale = Date.now() - cached.timestamp > STALE_THRESHOLD_MS;
if (isStale) {
// 2. Avvia revalidation in background (non bloccante)
fetcher()
.then((freshData) => {
cache.set(key, { data: freshData, timestamp: Date.now() });
})
.catch(console.error);
}
return { data: cached.data, isStale };
}
// 3. Nessuna cache: fetch bloccante
const freshData = await fetcher();
await cache.set(key, { data: freshData, timestamp: Date.now() });
return { data: freshData, isStale: false };
}
// Utilizzo nel frontend:
// const { data: userProfile, isStale } = await useStaleWhileRevalidate(
// `profile:${userId}`,
// () => api.getUserProfile(userId)
// );
// if (isStale) showBanner('Dati potenzialmente non aggiornati');
Trade-Off to Consider
Not all data tolerates eventual consistency. Product prices, sales of current accounts, the availability of airline tickets: these operations require strong consistency. Eventual consistency is appropriate for social feeds, counters of likes, analytics data, user profiles, non-critical inventories. Identify explicitly which data requires which level of consistency in your architecture.
Conclusions: Eventual Consistency as a Conscious Choice
Eventual consistency is not a weakness of distributed systems: it is a choice deliberate that enables scalability, availability and resilience that ACID systems they cannot offer. The key is to design the system so that inconsistencies temporary are invisible to the user (Optimistic UI, Read-Your-Writes) or explicitly communicated when unavoidable.
This article concludes the Event-Driven Architecture series. You now have an understanding complete with tools and patterns to build reliable distributed systems: from domain events to the Outbox Pattern, from DLQ to consumer idempotence, up to the strategies to manage any consistency in production.
Entire Series Event-Driven Architecture
- EDA Fundamentals: Domain Events, Commands and Message Bus
- Event Sourcing: State as an Immutable Sequence of Events
- CQRS: Separate Reading and Writing
- Saga Pattern: Distributed Transactions
- AWS EventBridge: Serverless Event Bus
- Dead Letter Queue and Resilience
- Idempotence in Consumers
- Outbox Pattern with CDC







