- Published on
Eventual Consistency in Practice — Building UIs and APIs That Handle Stale Data
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Distributed systems trade strong consistency for availability and performance. Data replicates asynchronously across nodes, creating a window where reads see stale data. Users refresh and see different results. The trick isn't eliminating stale reads (impossible)—it's making them invisible or manageable. We'll explore consistency tokens, optimistic UI updates, and patterns that provide strong consistency where it matters.
- Read-After-Write Consistency Problem
- Sticky Reads: Route to Primary After Write
- Version Vectors and Logical Clocks
- Causal Consistency Tokens
- UI Patterns for Eventual Consistency
- Detecting and Surfacing Stale Reads
- When Strong Consistency Is Worth the Cost
- Checklist
- Conclusion
Read-After-Write Consistency Problem
A user updates their profile photo, then immediately views it. With eventual consistency, they might see the old photo for several seconds—the write replicated to one region, the read hit a different region.
// Problem: user sees stale data immediately after write
class ProfileAPI {
async updatePhoto(userId: string, photoUrl: string): Promise<void> {
// Write to primary database
await this.primary.query(
`UPDATE users SET photo_url = $1, updated_at = NOW() WHERE id = $2`,
[photoUrl, userId]
);
// Replicas eventually see this update
}
async getProfile(userId: string): Promise<Profile> {
// Read might hit a replica that hasn't replicated the write yet
return await this.replica.query(
`SELECT id, name, photo_url FROM users WHERE id = $1`,
[userId]
);
}
}
Sticky Reads: Route to Primary After Write
After a write, route subsequent reads to the primary for a short window.
class StickyReadAPI {
private userWriteTimestamps = new Map<string, number>();
private stickInterval = 5000; // 5 seconds
async updatePhoto(userId: string, photoUrl: string): Promise<void> {
await this.primary.query(
`UPDATE users SET photo_url = $1, updated_at = NOW() WHERE id = $2`,
[photoUrl, userId]
);
// Mark this user as having a recent write
this.userWriteTimestamps.set(userId, Date.now());
}
async getProfile(userId: string): Promise<Profile> {
const lastWrite = this.userWriteTimestamps.get(userId) || 0;
const timeSinceWrite = Date.now() - lastWrite;
// If write was recent, read from primary (strongly consistent)
if (timeSinceWrite < this.stickInterval) {
return await this.primary.query(
`SELECT id, name, photo_url FROM users WHERE id = $1`,
[userId]
);
}
// Otherwise read from replica (faster, eventually consistent)
return await this.replica.query(
`SELECT id, name, photo_url FROM users WHERE id = $1`,
[userId]
);
}
// Cleanup old timestamps periodically
cleanup(): void {
setInterval(() => {
const now = Date.now();
for (const [userId, timestamp] of this.userWriteTimestamps.entries()) {
if (now - timestamp > this.stickInterval * 2) {
this.userWriteTimestamps.delete(userId);
}
}
}, 60000);
}
}
Version Vectors and Logical Clocks
Track causality: version vectors ensure you see all causally prior writes.
interface VersionVector {
[nodeId: string]: number;
}
class VersionVectorClock {
private nodeId: string;
private vector: VersionVector = {};
constructor(nodeId: string) {
this.nodeId = nodeId;
}
increment(): VersionVector {
this.vector[this.nodeId] = (this.vector[this.nodeId] || 0) + 1;
return { ...this.vector };
}
merge(other: VersionVector): void {
for (const [node, version] of Object.entries(other)) {
this.vector[node] = Math.max(this.vector[node] || 0, version);
}
}
happensBefore(v1: VersionVector, v2: VersionVector): boolean {
let atLeastOneSmaller = false;
for (const node of Object.keys(v1)) {
const v1Val = v1[node] || 0;
const v2Val = v2[node] || 0;
if (v1Val > v2Val) return false;
if (v1Val < v2Val) atLeastOneSmaller = true;
}
return atLeastOneSmaller;
}
concurrent(v1: VersionVector, v2: VersionVector): boolean {
return !this.happensBefore(v1, v2) && !this.happensBefore(v2, v1);
}
}
// Example: tracking thread causality
class ThreadService {
private clock = new VersionVectorClock('node-1');
async postMessage(userId: string, threadId: string, text: string): Promise<string> {
const version = this.clock.increment();
const result = await this.db.query(
`INSERT INTO messages (user_id, thread_id, text, version)
VALUES ($1, $2, $3, $4)
RETURNING id`,
[userId, threadId, text, JSON.stringify(version)]
);
return result.rows[0].id;
}
async getThread(threadId: string, clientVersion: VersionVector): Promise<Message[]> {
// Return only messages that happen-after client's known version
const messages = await this.db.query(
`SELECT id, text, version FROM messages WHERE thread_id = $1`,
[threadId]
);
return messages.rows.filter(msg => {
const msgVersion = JSON.parse(msg.version);
return this.clock.happensBefore(clientVersion, msgVersion);
});
}
}
Causal Consistency Tokens
Instead of full version vectors, use a lightweight token representing a consistent snapshot.
interface ConsistencyToken {
logPosition: number; // Log entry number from primary
timestamp: number;
}
class ConsistencyTokenAPI {
async updateProfile(userId: string, updates: any): Promise<ConsistencyToken> {
const result = await this.primary.query(
`UPDATE users SET name = $1, updated_at = NOW() WHERE id = $2
RETURNING updated_at`,
[updates.name, userId]
);
const logPosition = await this.getReplicationLogPosition();
return {
logPosition,
timestamp: result.rows[0].updated_at.getTime(),
};
}
async getProfile(userId: string, token?: ConsistencyToken): Promise<Profile> {
if (token) {
// Wait for replica to catch up to this log position
await this.waitForReplication(token.logPosition);
}
return await this.replica.query(
`SELECT id, name FROM users WHERE id = $1`,
[userId]
);
}
private async getReplicationLogPosition(): Promise<number> {
const result = await this.primary.query(
`SELECT pg_current_wal_lsn()::bigint as lsn`
);
return result.rows[0].lsn;
}
private async waitForReplication(requiredPosition: number): Promise<void> {
const maxWait = 5000; // 5 second timeout
const startTime = Date.now();
while (Date.now() - startTime < maxWait) {
const result = await this.replica.query(
`SELECT pg_last_wal_receive_lsn()::bigint as lsn`
);
if (result.rows[0].lsn >= requiredPosition) {
return;
}
await sleep(100);
}
throw new Error('Replica replication lag exceeds tolerance');
}
}
UI Patterns for Eventual Consistency
Optimistic Updates: Update UI immediately, sync to server asynchronously.
class OptimisticUI {
private optimisticUpdates = new Map<string, any>();
async updateTodoStatus(todoId: string, completed: boolean): Promise<void> {
// Update UI immediately (optimistic)
this.optimisticUpdates.set(todoId, { completed });
this.notifyUI();
try {
// Send to server
await this.api.updateTodo(todoId, { completed });
} catch (error) {
// Rollback on failure
this.optimisticUpdates.delete(todoId);
this.notifyUI();
throw error;
}
}
getTodo(todoId: string): Todo {
const optimistic = this.optimisticUpdates.get(todoId);
if (optimistic) {
// Merge optimistic update with server state
return { ...this.serverState[todoId], ...optimistic };
}
return this.serverState[todoId];
}
private notifyUI(): void {
// Trigger UI re-render
}
}
Processing State: Show "saving..." while update is in-flight.
interface UIState {
data: any;
status: 'idle' | 'processing' | 'error' | 'success';
error?: string;
}
class ProcessingStateUI {
private state: UIState = { data: null, status: 'idle' };
async updateProfile(userId: string, updates: any): Promise<void> {
this.state.status = 'processing';
this.notifyUI();
try {
const result = await this.api.updateProfile(userId, updates);
this.state.data = result;
this.state.status = 'success';
this.notifyUI();
// Clear success state after 2 seconds
setTimeout(() => {
this.state.status = 'idle';
this.notifyUI();
}, 2000);
} catch (error) {
this.state.status = 'error';
this.state.error = (error as Error).message;
this.notifyUI();
}
}
render(): string {
switch (this.state.status) {
case 'processing':
return '<div>Saving...</div>';
case 'error':
return `<div class="error">${this.state.error}</div>`;
case 'success':
return '<div class="success">Saved!</div>';
default:
return `<div>${JSON.stringify(this.state.data)}</div>`;
}
}
private notifyUI(): void {
// Trigger re-render
}
}
Polling for Consistency: After an update, poll until you see the change reflected.
class PollingForConsistency {
async updateAndAwaitConsistency(
userId: string,
updates: any,
maxWaitMs = 10000
): Promise<User> {
const startTime = Date.now();
// Send update
await this.api.updateUser(userId, updates);
// Poll until we see the update
while (Date.now() - startTime < maxWaitMs) {
const user = await this.api.getUser(userId);
// Check if update is reflected
if (user.name === updates.name) {
return user;
}
await sleep(200);
}
throw new Error('Update not reflected after maximum wait time');
}
async submitFormWithWaitForUI(userId: string, formData: any): Promise<void> {
const loadingState = true;
this.notifyUI({ loading: true });
try {
const updated = await this.updateAndAwaitConsistency(userId, formData);
this.state = updated;
this.notifyUI({ loading: false, success: true });
} catch (error) {
this.notifyUI({ loading: false, error: 'Update failed' });
}
}
private notifyUI(state: any): void {
// Trigger UI update
}
private state: any;
}
Detecting and Surfacing Stale Reads
When data is old, tell the user.
interface StaleAwareResponse<T> {
data: T;
isStale: boolean;
stalenessTtl?: number; // How many seconds old
consistencyToken?: ConsistencyToken;
}
class StaleAwareAPI {
async getProfileWithStaleness(userId: string): Promise<StaleAwareResponse<Profile>> {
const result = await this.replica.query(
`SELECT id, name, updated_at FROM users WHERE id = $1`,
[userId]
);
const profile = result.rows[0];
const replicationLag = await this.getReplicationLag();
const isStale = replicationLag > 1000; // 1 second lag
const stalenessTtl = isStale ? replicationLag / 1000 : undefined;
return {
data: profile,
isStale,
stalenessTtl,
};
}
private async getReplicationLag(): Promise<number> {
// In milliseconds
const result = await this.primary.query(
`SELECT EXTRACT(EPOCH FROM (pg_last_xact_replay_timestamp() - NOW())) * 1000 as lag_ms`
);
return Math.max(0, result.rows[0].lag_ms);
}
}
// UI can show warning when data is stale
interface ProfileComponentProps {
response: StaleAwareResponse<Profile>;
}
function ProfileComponent(props: ProfileComponentProps) {
return (
<div>
{props.response.isStale && (
<div className="stale-warning">
Data is {props.response.stalenessTtl} seconds old.{' '}
<button onClick={() => window.location.reload()}>Refresh</button>
</div>
)}
<h1>{props.response.data.name}</h1>
</div>
);
}
When Strong Consistency Is Worth the Cost
Strong consistency (read-after-write) adds latency but is critical for:
- Financial transactions
- Inventory management
- User authentication
- Critical business state
class StrongConsistencyQueries {
async getAccountBalance(accountId: string): Promise<number> {
// Always read from primary for critical data
const result = await this.primary.query(
`SELECT balance FROM accounts WHERE id = $1`,
[accountId]
);
return result.rows[0].balance;
}
async debitAccount(accountId: string, amount: number): Promise<void> {
// Transactional, blocking read-modify-write
const result = await this.primary.query(
`UPDATE accounts
SET balance = balance - $1
WHERE id = $2 AND balance >= $1
RETURNING balance`,
[amount, accountId]
);
if (result.rows.length === 0) {
throw new Error('Insufficient balance');
}
}
// Cache critical data with short TTL
async getCachedBalance(accountId: string): Promise<number> {
const cached = await this.cache.get(`balance:${accountId}`);
if (cached !== null) {
return cached;
}
const balance = await this.getAccountBalance(accountId);
await this.cache.set(`balance:${accountId}`, balance, 5); // 5 second TTL
return balance;
}
}
Checklist
- Use sticky reads after writes for user-facing data
- Implement version vectors for causal consistency where needed
- Provide consistency tokens in APIs for client-side coordination
- Use optimistic updates + processing states in UI
- Implement polling for critical updates
- Surface staleness to users when appropriate
- Route critical reads to primary database
- Test consistency under network partitions
- Monitor replication lag and alert on excessive delays
Conclusion
Eventual consistency is a feature, not a bug. The trick is making stale reads invisible or acceptable. Use sticky reads for user writes, optimistic UI updates, and explicit consistency tokens when needed. Keep strong consistency for truly critical data—financial transactions, authentication, inventory. For everything else, embrace eventual consistency and build UIs that gracefully handle temporary inconsistencies.