- Published on
Inconsistent Reads — The Eventual Consistency Shock
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
You write to the primary database. You read from a replica. The replica hasn't synced yet. You see stale data. This is read-your-own-writes consistency — one of the most common and confusing distributed systems problems.
Eventual consistency is a trade-off, not a bug. But it requires deliberate design.
- The Problem Scenarios
- Fix 1: Read-Your-Own-Writes
- Fix 2: Cache Invalidation on Write
- Fix 3: Optimistic UI Updates
- Fix 4: Version Vectors for Conflict Detection
- Fix 5: Indicate Staleness in the UI
- Consistency Levels Cheatsheet
- Conclusion
The Problem Scenarios
Scenario 1: User updates profile name
t=0ms: Write to primary DB (name = "Alice")
t=5ms: User refreshes page
t=5ms: Read from replica (replica lag = 200ms)
t=5ms: Replica still has old name = "Bob"
User sees old data — confusion ensues
Scenario 2: Inventory update
t=0ms: Admin marks item as "out of stock" in DB
t=0ms: Redis cache still has "in stock" (TTL = 60s)
t=30s: Users can still add item to cart
t=60s: Cache expires, users finally see "out of stock"
Scenario 3: Order placed, dashboard not updated
t=0ms: Order created in orders service
t=0ms: Event published to Kafka
t=500ms: Analytics service processes event
t=0ms: User opens dashboard — order not visible
User thinks order failed — places second order
Fix 1: Read-Your-Own-Writes
// Strategy: route reads to primary for a short window after writes
class ConsistentUserRepository {
private recentWrites = new Map<string, number>() // userId → write timestamp
private CONSISTENCY_WINDOW_MS = 5_000 // 5s after write, read from primary
async update(userId: string, data: Partial<User>): Promise<User> {
const updated = await this.primaryDb.user.update({ id: userId }, data)
// Mark this user as "recently written"
this.recentWrites.set(userId, Date.now())
setTimeout(() => this.recentWrites.delete(userId), this.CONSISTENCY_WINDOW_MS)
return updated
}
async findById(userId: string): Promise<User | null> {
const writeTime = this.recentWrites.get(userId)
if (writeTime && Date.now() - writeTime < this.CONSISTENCY_WINDOW_MS) {
// Recent write — read from primary to avoid stale data
return this.primaryDb.user.findById(userId)
}
// No recent write — safe to read from replica
return this.replicaDb.user.findById(userId)
}
}
// Alternative: pass a session token that routes reads to primary
// PostgreSQL: use session-level read from primary via read preference
// After write: store timestamp in session cookie
res.cookie('db_primary_until', Date.now() + 5000, { httpOnly: true })
// Before read: check cookie
function chooseDatabase(req: Request): Database {
const primaryUntil = req.cookies.db_primary_until
if (primaryUntil && Date.now() < parseInt(primaryUntil)) {
return primaryDb
}
return replicaDb
}
Fix 2: Cache Invalidation on Write
// Clear cache immediately when data changes — don't wait for TTL
class UserService {
async updateProfile(userId: string, data: UpdateProfileDto): Promise<User> {
// 1. Write to database
const user = await db.user.update({ id: userId }, data)
// 2. Immediately invalidate all related cache keys
await Promise.all([
redis.del(`user:${userId}`),
redis.del(`user:${userId}:profile`),
redis.del(`user:${userId}:public`),
])
// 3. Optionally pre-warm cache with new value
await redis.setex(
`user:${userId}`,
300,
JSON.stringify(user)
)
return user
}
async getProfile(userId: string): Promise<User> {
const cached = await redis.get(`user:${userId}`)
if (cached) return JSON.parse(cached)
const user = await db.user.findById(userId)
await redis.setex(`user:${userId}`, 300, JSON.stringify(user))
return user
}
}
Fix 3: Optimistic UI Updates
// Don't wait for the server to show the user their change
// Update the UI immediately, revert if server fails
// React: optimistic update pattern
function ProfileEditor({ user }: { user: User }) {
const [displayName, setDisplayName] = useState(user.name)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSave = async (newName: string) => {
const previousName = displayName
// Optimistic update: show new value immediately
setDisplayName(newName)
setSaving(true)
try {
await api.updateProfile({ name: newName })
// Success — optimistic update was correct
} catch (err) {
// Failure — revert to previous value
setDisplayName(previousName)
setError('Failed to save. Please try again.')
} finally {
setSaving(false)
}
}
return (
<div>
<span>{displayName}</span>
{saving && <Spinner />}
{error && <Alert>{error}</Alert>}
</div>
)
}
Fix 4: Version Vectors for Conflict Detection
// Track which version of data the client has seen
// Server can detect if client is working with stale data
interface VersionedResponse<T> {
data: T
version: number // Monotonically increasing
timestamp: number
}
// Server: include version in response
app.get('/api/users/:id', async (req, res) => {
const user = await db.user.findById(req.params.id)
res.json({
data: user,
version: user.version,
timestamp: user.updatedAt.getTime(),
})
})
// Client: send version with update
app.put('/api/users/:id', async (req, res) => {
const { data, version: clientVersion } = req.body
const current = await db.user.findById(req.params.id)
if (current.version !== clientVersion) {
return res.status(409).json({
error: 'Stale data — please refresh and try again',
currentVersion: current.version,
yourVersion: clientVersion,
diff: computeDiff(current, data),
})
}
const updated = await db.user.update(
{ id: req.params.id, version: clientVersion }, // Optimistic lock
{ ...data, version: clientVersion + 1 }
)
res.json({ data: updated, version: updated.version })
})
Fix 5: Indicate Staleness in the UI
// When strong consistency is too expensive, be transparent about staleness
interface CachedData<T> {
data: T
cachedAt: number
isStale: boolean
}
async function getProductWithStaleness(productId: string): Promise<CachedData<Product>> {
const cached = await redis.get(`product:${productId}`)
if (cached) {
const { data, cachedAt } = JSON.parse(cached)
const ageMs = Date.now() - cachedAt
return {
data,
cachedAt,
isStale: ageMs > 30_000, // Mark as stale if > 30s old
}
}
const product = await db.product.findById(productId)
const now = Date.now()
await redis.setex(`product:${productId}`, 60, JSON.stringify({
data: product,
cachedAt: now,
}))
return { data: product, cachedAt: now, isStale: false }
}
// Frontend: show staleness indicator
// <ProductCard
// product={data}
// showStalenessWarning={isStale}
// onRefresh={() => refetch()}
// />
Consistency Levels Cheatsheet
| Consistency Level | Use Case | Trade-off |
|---|---|---|
| Strong consistency | Financial transactions, inventory | Higher latency, lower availability |
| Read-your-own-writes | Profile updates, settings | Route writes and immediate reads to primary |
| Monotonic reads | Feed, timeline | Sticky sessions to same replica |
| Eventual consistency | Analytics, recommendations, counters | Cheapest, most scalable |
| Causal consistency | Collaborative docs | Complex to implement |
Conclusion
Eventual consistency is not the enemy — it's a deliberate trade-off that enables horizontal scaling. The problem is when it's invisible to developers and users. Fix it by being explicit: use read-your-own-writes routing for critical user flows, invalidate caches immediately on write rather than waiting for TTL, implement optimistic UI updates so users see changes instantly, and show staleness indicators when serving cached data. The worst outcome is a user who retries an operation because they don't know if it worked — that turns eventual consistency into a correctness bug.