System Design · Module 6 · Lesson 3 of 4 · 40 min
Invalidation Races and Freshness Guarantees
Reproduce a stale-fill race, define how quickly updates become visible, and choose a stronger policy when TTL staleness is unacceptable.
01 The idea
The problem this lesson solves
You update the database and delete the cache key. A moment later the cache contains the old value again. No second writer changed the database. An earlier reader finished late and refilled stale data after your deletion. Cache invalidation is a concurrency problem: the order of reads, writes and fills matters as much as the command used to remove a key.
You should recognize the application, the database and the request path. Treat the database as the authority unless explicitly stated otherwise.
02 Worked example
Refill an old value after invalidation
03 The method
Understand the mechanism
Define the freshness promise per operation
Freshness describes how current a returned value must be relative to authoritative changes. A public content page may tolerate a short stale interval; an access revocation or ownership decision may not. Start with the user-visible contract rather than a default TTL.
Read-your-writes means a client can observe its own completed update on a later read under the stated session or operation scope. It is weaker than promising that every reader everywhere immediately observes the newest value. Monotonic reads prevent a client from going backward to an older observed version.
A cache can violate these expectations even when the database itself provides suitable guarantees. If the app reads an old local copy after a successful write, the user experiences stale behavior despite the authoritative row being correct.
Separate strict operations from tolerant ones where the product allows it. An owner’s confirmation path might read the authority, while anonymous browsing uses an explicitly bounded-staleness path. The distinction must be real in the implementation and documentation.
Walk the stale-fill race
Initially the database holds v1 and the cache is empty. Reader R misses and reads v1 from the database, then pauses before filling the cache. Writer W commits v2, deletes the cache key and reports success. R resumes and stores v1 with a fresh TTL.
The cache now contains old data after the writer’s invalidation. The delete command worked exactly as requested; it could not delete a fill that had not happened yet. “Delete after write” is therefore not by itself proof of immediate freshness.
If the writer instead deletes before committing, another reader can miss during the gap, read v1 and refill it before or after the commit. Changing the order alone does not eliminate every interleaving.
Draw the race with separate database and cache columns. This makes the authority and copy transitions visible. A single arrow labeled update cache hides the interval where the problem occurs.
Understand what TTL can and cannot bound
A TTL begins according to the cache insertion or refresh operation. If R read v1 long ago and fills it after W’s update, its new TTL starts late. The maximum age relative to the latest database change is not necessarily equal to the configured TTL.
To claim a bound, account for the age of the source read, maximum fill delay, expiry behavior and any upstream caches. An unbounded delayed fill destroys a simple “all readers fresh within thirty seconds of update” argument.
TTL remains useful: it limits residence of a particular entry and provides eventual refresh opportunities under ordinary operation. It is a repair mechanism for some stale-copy situations, not a substitute for a strict consistency protocol.
Be careful with stale-while-revalidate and stale-if-error policies. They intentionally permit serving older data under defined conditions. They can improve availability for suitable content, but they weaken freshness and must not be applied blindly to permissions or revoked destinations.
Compare invalidation, updates and versioned fills
Invalidation removes a copy and lets a later read refill it. Updating the cached value can reduce the next miss, but concurrent writers and delayed fills still need ordering. A slower writer or reader can overwrite a newer cache value unless the cache transition rejects older versions.
Versioned values can help if the compare-and-store is atomic and there is trustworthy version knowledge to compare against. Merely placing version=1 beside a value is insufficient when the cache is empty and accepts any incoming fill.
A design may maintain a version watermark, use generation-specific keys, or validate against an authoritative version before accepting a fill. Each adds state and ordering requirements. If validation and fill are separate and uncoordinated, another update can occur in between.
For a strict read path, bypassing the cache may be the clearest first choice, provided the authoritative read itself meets the required consistency. The performance cost is explicit and can be measured. Stronger cache coordination should be introduced only with a complete failure and race argument.
Keep multiple layers and messages in the model
A local app cache and a shared cache are two copies. Deleting the shared entry does not remove A’s local entry unless another mechanism connects them. Browser or CDN caching can retain an HTTP response independently of both.
Invalidation messages can arrive late, out of order, more than once or not at all under the transport and recovery model. Consumers need appropriate version/order handling and a way to recover after disconnection. A message bus labeled reliable does not eliminate the need to define these behaviors.
Reordering matters: an old update message must not replace a newer value. Duplicate invalidation is often harmless, while duplicate write application may not be. The event’s semantics determine what idempotency means.
Include tenant, representation and query identity in invalidation. Removing a7’s object key may not invalidate cached owner listings or derived search results that contain its old destination. Those projections need their own consistency contracts.
Test freshness with controlled interleavings
Ordinary sequential tests rarely expose late-fill races. Add a barrier in a practice loader after the authoritative read and before the cache write. Pause R at that barrier, perform W’s update and invalidation, then resume R. Inspect the cached value.
Repeat with multiple writers, delayed invalidation, local and shared caches, and a restarted invalidation consumer. Check the declared contract rather than assuming every stale observation is equally wrong. A permitted thirty-second-old public value differs from a stale authorization decision.
Observe value versions and fill timing where feasible without exposing sensitive content. A high hit ratio does not indicate freshness. Metrics need to reflect invalidation failures, rejected stale fills and divergence checks when those mechanisms are used.
The design is complete when it explains both the normal fast path and why an old operation cannot violate the promised read behavior. If the mechanism only reduces the probability of stale data, describe it as risk reduction rather than a guarantee.
Guided tutorial
Reproduce the exact race
Use three states on paper: database=v1, cache=missing, R holds no value. Let R read v1 and pause. Let W commit v2 and delete the key. Resume R’s fill with v1. The final database and cache disagree even though deletion occurred after the write.
Calculate the TTL limitation
Assume R pauses for twenty seconds after reading and then fills v1 with a thirty-second TTL after W’s update. Identify the extra delay introduced by the late fill. Explain why TTL alone is not a thirty-second bound measured from the authoritative update.
Review a proposed version check
Store versions with values, then consider an empty cache that has forgotten the newest version. Can it reject an old v1 fill? Not without another source of version knowledge. Add a watermark or choose an authoritative bypass and explain the atomic boundary required.
Write two read contracts
Define a tolerant public read and a strict post-update verification read. Draw the path each uses, including local, shared and HTTP caches. Test the delayed-reader timeline against both and state exactly which stale result is permitted.
Concurrent event trace
R: cache miss → database returns v1 → pause
W: commit v2 → delete cache → acknowledge update
R: resume → cache.set(v1, ttl)
# Result: stale v1 exists after the delete.
# A stronger contract must reject this fill or avoid this read path.Check your result
Pause R after reading v1, commit v2 and delete the key in W, then resume R. The cache contains stale v1. Explain how your chosen freshness contract handles or prevents that fill.
Reproduce a late stale fill
Explicit variables model the paused reader without threads or timing luck. The interleaving is the defect; a delete command alone cannot prevent it.
database = {"a7": (1, "old")}
cache = {}
reader_value = database["a7"] # R reads v1, then pauses.
database["a7"] = (2, "new") # W commits v2.
cache.pop("a7", None) # W invalidates.
cache["a7"] = reader_value # R resumes its old fill.
print("database_version=", database["a7"][0], sep="")
print("cache_version=", cache["a7"][0], sep="")
print("stale=", cache["a7"][0] < database["a7"][0], sep="")
Expected output
database_version=2
cache_version=1
stale=TrueThe cache fill occurs after the delete and still contains v1. A strict fix needs an authority/version protocol or a different read path, not merely the same commands with optimistic comments.
04 Cheat sheet
Review the decisions
Use this table to explain the mechanism and its limit without memorizing a product name.
| Choice or concept | Remember | Check |
|---|---|---|
| Plain cache-aside | Simple fast path | Overlapping stale fills remain possible |
| TTL | Bounds copy lifetime | Does not eliminate the race |
| Version-aware coordination | Rejects obsolete work under its contract | Needs authoritative ordering/progress |
| Bypass sensitive reads | Uses the stronger authority directly | Higher origin cost |
05 Where & why
Check the primary references
Use official references for protocol and implementation details. The questions here are teaching prompts.
06 Interview questions
Explain the reasoning out loud
Answer before opening the explanation. The questions progress from vocabulary to decisions and failure behavior.
What does Invalidation mean?
What does Stale fill mean?
What does Version check mean?
Why is delete-after-write insufficient?
Does a version field alone solve the race?
When is bounded staleness acceptable?
What must a revocation guarantee specify?
How can negative caching hide a new object?
07 Practice problems
Practice a new case
Solve the new cases before revealing hints. Complete worked solutions follow the set.
Hit rate
EasyShow the hint
Name the race
EasyShow the hint
Staleness bound
MediumShow the hint
Negative create
MediumShow the hint
Version proof
MediumShow the hint
Correct redirect update
HardShow the hint
Worked solutions
1. Hit rate
18 / (18 + 6) = 0.75, or 75 percent.
2. Name the race
The late fill writes old v1 after the newer commit and invalidation.
3. Staleness bound
It normally expires at t=50, not thirty seconds after the writer’s update. Other stale-serving or refresh policies can change the full contract.
4. Negative create
Readers can see not found for the remaining negative lifetime unless creation invalidates it or another freshness mechanism intervenes.
5. Version proof
The claim is incomplete. The fill must compare against trustworthy newer state under a coordinated protocol; storing a timestamp without enforcing ordering does not reject a stale write.
6. Correct redirect update
Do not promise immediate freshness with plain cache-aside and TTL. Read through a coordinated version-aware mechanism that rejects stale fills and defines concurrent-read ordering, or bypass the cache for these mutable links. State the acknowledgment boundary and handle already-in-flight reads explicitly.