Module 05 · Lesson 03

Invalidation: TTL, versions, and the delete that arrives late

The hard part of caching is not storing the answer. It is knowing when the answer changed.

Advanced 7 min read Updated 2026-11-01

What you will be able to do

  • Pick an invalidation approach for a given freshness requirement
  • Explain the double-delete and version-key techniques
  • Describe how event-driven invalidation fails and how to make it reliable
  • Define a measurable staleness bound

Four mechanisms

MechanismFreshnessWeakness
TTL onlyBounded by TTLWorst case is a full TTL of stale data
Delete on writeNear-instantRaces with concurrent reads; keys can be re-cached stale
Version in the keyAtomicOld versions linger until they expire; more keys
Change eventSeconds, if reliableDropped or reordered messages leave silent staleness

In practice you combine them: a short TTL as a backstop, event-driven deletion for promptness, and versioned keys where correctness matters most. The TTL is the insurance policy — it guarantees that every mistake eventually heals.

The version-key trick

Instead of storing product:42, store product:42:v7, where 7 comes from the row's version column or a monotonic update counter. A writer bumps the version in the database; readers derive the key from the version they were given. Old entries become unreachable immediately and expire on their own. There is no delete to race, because the key name itself changed.

read(id):  v = version_store.get(id)      # e.g. Redis or the row itself
           hit = cache.get(f"product:{id}:v{v}")
           if hit: return hit
           row = db.read(id)
           cache.set(f"product:{id}:v{row.version}", row, ttl=600)
           return row

write(id): row = db.update(...)  # version increments inside the transaction
           version_store.set(id, row.version)
Why it works
You never mutate a cached value, so no reader can observe a half-updated object. The only shared mutable thing is the version pointer, and a read of a slightly older version returns a coherent older object — which is exactly the staleness you already agreed to.

Event-driven invalidation, done honestly

Publish a change event when a row changes, and have every cache consumer delete the relevant keys. The failure mode is that the event is lost, delayed, or applied twice.

Publish from the same transaction
An outbox row committed with the data change, then relayed, guarantees a change never exists without a corresponding event.
Make handling idempotent
Deleting a key twice is harmless. Deleting in the wrong order is not, so include the version and ignore events older than what the cache already reflects.
Fail loudly
If a consumer cannot apply an event, retry with a dead-letter queue and an alert. A silently skipped invalidation is indistinguishable from correct operation until someone notices wrong data.
Keep the TTL
Even with events, every key keeps a TTL. The event is for freshness; the TTL is for correctness after the event you never got.

Bounded staleness is a product conversation

Write the requirement as a number: profile edits visible within 5 seconds, inventory counts within 30 seconds, recommendation rails within 15 minutes. Then choose a mechanism that provably meets it. Without a number, every discussion becomes a contest of intuition, and someone eventually ships a cache with a one-hour TTL in a flow that needed one second.

Instead
Invalidating only the exact key while list and feed caches still hold the old value
Do this
Map every cached view to the entities it depends on and invalidate the views too
Instead
Deleting the key before the database write commits
Do this
Write first, then invalidate — a reader between the two can re-cache the old value
Instead
Assuming a cache clear is instant across regions
Do this
Propagate invalidation with a version or a short TTL, and accept a bounded window

Worth remembering

  • TTL is the only invalidation that cannot be missed; everything else can drop a message.
  • Versioned keys make invalidation atomic: change the version and old entries become unreachable.
  • Invalidation events must be delivered at-least-once and be idempotent, or the cache drifts.
  • State the maximum staleness in seconds. If nobody can name it, the requirement is missing.

Checkpoint

Answer all 3 questions to finish the lesson.
1. Why does a versioned key avoid the classic delete race?
Old and new values live under different keys. A slow reader may cache its stale value under the old key, which nobody will read again.
2. Even with reliable invalidation events, why keep a TTL on every key?
Events can fail silently. A TTL bounds the damage from any missed invalidation to a known time window.
3. Which ordering is correct for a write-then-invalidate design?
Deleting before the commit leaves a window where a reader re-populates the key with the old value, which then survives until its TTL expires.