Interview question. A product page uses cache-aside with Redis and a relational database. Product p17 changes from ₹999 to ₹799. The write commits, the application deletes the cache key, and a customer still sees ₹999 for several minutes. There is only one database writer and no failed transaction. Reconstruct a possible sequence, explain which component must enforce freshness, and propose a design for a price display that tolerates a few seconds of staleness. Would your answer change for the final checkout price?
The cache is a performance copy. The database is authoritative for this exercise. There is no promise that the cache and database can commit an update atomically.
Think first
A product read begins before an administrator commits a new price. The write deletes the Redis key, then the old read fills it after the deletion. Give the event timeline and a bounded-staleness solution. What would change for a checkout quote?
Take a few minutes to form an approach, then compare it with the worked explanation.
Reveal the worked explanation
Deleting the key does not cancel a read already in progress
Start with an empty cache. Request A misses and begins reading the database. Before its reply arrives, an administrator changes the price. Request B commits the new price and deletes the cache key. Request A finally receives the old query result and writes it into the cache.
An old database read fills the cache after the new price has committed and invalidated the key
A: cache miss for p17
A: starts database read of price 999
B: commits price 799
B: deletes cache key p17
A: receives earlier result 999
A: sets cache key p17 = 999
C: reads cache p17 = 999
There is no impossible database behavior in this timeline. The cache has two independent mutation paths: fills triggered by reads and invalidations triggered by writes. Their messages can arrive in a different order from the business events. Meta's engineering account of cache consistency describes this exact class of stale-fill race.
Notice why merely switching to “delete after database commit” is incomplete. We already did that. A delayed reader can repopulate an entry after deletion. A TTL bounds the duration if expiry continues to work, but it does not prevent the race. A longer TTL increases exposure. Deleting twice after a delay may reduce some windows, but it is not a correctness proof against an arbitrarily delayed fill.
First decide what the customer is being promised
Suppose the product team accepts a price display that can be up to five seconds old. The simplest useful design may combine short TTLs, invalidation on successful writes, and monitoring of discrepancies. To promise a five-second maximum age, also bound the age of a fill: reject results whose authoritative snapshot is too old, including any snapshot retained by an already-open transaction, include the TTL in that total age budget, and account for replica lag. A TTL measured only from cache insertion does not bound the age of an arbitrarily delayed database result. We should measure the age and frequency of stale displays rather than promise every read sees the latest transaction.
Checkout is different. A customer must be told which price the server is actually accepting. In this design, the checkout command obtains an authoritative price or a valid server-issued quote with clearly defined expiry and reservation terms. It never treats a cached product-card price as permission to charge that amount. That separation is not a cache workaround. It is an explicit business contract.
For displays needing stronger freshness, use versioned data and an admission rule for cache fills. The database might return (price=799, version=42). A cache fill based on version 41 must not overwrite a version 42 that the cache already knows about. A conditional update, performed atomically at the cache, can reject older versions.
But version checks have a less obvious failure. What if the cache evicts the version 42 record before the delayed version 41 arrives? If the cache loses its only knowledge of the latest version, an old fill can again appear acceptable. The protection depends on where the high-water mark or invalidation generation survives. Keep that information under an appropriate retention and authority contract, or accept that the cache provides bounded staleness rather than strict ordering. A sequence number in an evictable value is not sufficient by itself.
There is also a second boundary. If a fill reads from a lagging database replica, it may obtain the old price after the primary committed the new one. Cache invalidation cannot make a replica catch up. Route version-sensitive fills to an authority that meets the freshness requirement, or attach replication-position checks to the path.
How would you know the fix works?
I would test the specific interleaving above, including an intentionally delayed fill and an eviction between versions. Tests should verify that old data cannot overwrite newer data under the selected guarantee. I'd also inject dropped or reordered invalidations, because a beautifully implemented compare-and-set does not help if the required generation never arrives.
Operationally, measure invalidation delivery delay, stale-read age, and disagreement between sampled cache results and authoritative reads. Sampling must be bounded so validation traffic does not overwhelm the database. Treat a mismatched version differently from a temporarily missing key. One might be a genuine inconsistency and the other merely a cache miss.
Finally, evaluate key semantics. Product description, price display, active promotion, and checkout quote might look like one “product object” to the UI, but they have different freshness and authorization requirements. Caching them together under one long TTL can accidentally give the most sensitive field the weakest policy.
What I would say in an interview: The stale price can return because a cache fill that read the old database state arrives after an invalidation. I'll first specify a freshness budget, then protect the fill/invalidation race with version-aware updates where needed, and keep the ordering evidence long enough to matter. Checkout uses an authoritative price contract rather than trusting the display cache.
The price is now handled. At noon, one especially popular product's cache entry expires. Thousands of readers rush to refill it together. The next problem is not a stale value. It is too many perfectly reasonable reads.
Go deeper
If the caching system requires monotonic versions, define whether versions are per object, per namespace, or global. They must be comparable for updates to the same object and protected against reuse after deletion and recreation. A deleted object can have a tombstone version so an old fill does not revive it. An evicted tombstone loses that ordering evidence unless another authority preserves it.
An external invalidation stream introduces its own replay and retention requirements. A consumer falling behind the stream must not silently declare its local cache current. It may need to discard the affected namespace, resynchronize generations, or temporarily bypass the cache.
For an interview, distinguish client-visible freshness from internal cache coherence. A bounded five-second stale display does not require proving strict linearizability of every cache operation. An authorization or payment decision might.
Check your reasoning
Construct the delayed cache-fill race rather than merely saying invalidation is unreliable.
Distinguish bounded-stale price display from an authoritative checkout price decision.
Describe how version-aware cache writes prevent an older known version overwriting newer data.
Recognize that evicting the only remembered high-water mark can weaken this protection.
Explain how to test delayed fills and observe cache/database divergence without overwhelming the database.
Common mistake: Claiming delete-after-commit or a version stored only in an evictable cache object always guarantees fresh reads.
Read the answer explanation
Choose a freshness bound or enforce ordering of cache fills against retained versions. — An old cache fill can race after deletion. Protect ordering or explicitly accept bounded stale reads.
Apply it elsewhere
An authorization-policy cache receives an invalidation, then a delayed old-policy fill. Is the same five-second display freshness policy acceptable?
Compare your approach
No. The authorization contract may require synchronous checking or stronger generation fencing and a fail-closed fallback.
One more constraint
Where should the latest known version live if the cached value can be evicted?
Quick revision
A cache-aside stale-fill race occurs when a read fetches an old database value, a write commits a new value and invalidates the cache, and the delayed read then fills the cache with the old value. Deleting after commit does not cancel the delayed fill. Define the freshness contract first. For bounded-stale displays, invalidation plus TTL and measurement may suffice, provided the total freshness budget also bounds fill age and any replica lag. For stricter paths, use version-aware fills with ordering evidence that cannot disappear unnoticed when values are evicted. A price shown on a product card is not authority to charge that price at checkout.