Published on

Inconsistent Reads — The Eventual Consistency Shock

Authors

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

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 LevelUse CaseTrade-off
Strong consistencyFinancial transactions, inventoryHigher latency, lower availability
Read-your-own-writesProfile updates, settingsRoute writes and immediate reads to primary
Monotonic readsFeed, timelineSticky sessions to same replica
Eventual consistencyAnalytics, recommendations, countersCheapest, most scalable
Causal consistencyCollaborative docsComplex 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.