Interlude · Spirit
The Invalidation Paradox
The hardest problem in the course
The moment you cache something, the same fact exists in two places:
- The original, in your database. This is the truth.
- The copy, in your cache. This is what your users actually see.
Two places holding one fact can disagree — and when they do, the cache has no idea. Nothing tells it the database moved on without it.
Staleness is a cached copy no longer matching the truth it was copied from. It is not an error state — a stale cache does not crash, log a warning, or fail a health check. It quietly serves a wrong answer with complete confidence.
Three questions
Before choosing a technique, answer these for the data in question:
1. How wrong can it be? A view count off by three is fine. A price off by £5 is a legal problem.
2. For how long? Seconds, minutes, hours?
3. What does being wrong cost? A stale description is an annoyance. A stale stock count is an oversold order, a refund, and an angry customer.
These answers give you your TTL and tell you how much effort invalidation deserves. Data that can be five minutes stale needs a TTL and nothing else. Data that must be correct within a second needs real machinery.
Invalidate-on-write
The standard approach, and the natural partner to cache-aside from Lesson 8:
On write:
database.update(key, new_value)
cache.delete(key)
On the next read:
cache miss → load from database → cache the fresh value
Update the truth, remove the copy, let it rebuild on demand.
Delete — do not update in place. Writing the new value into the cache looks better, because it saves the next reader a miss. It is not. Two administrators change a price at nearly the same time:
t=0 Writer A: database ← 10
t=1 Writer B: database ← 12
Database is now 12. ✓ correct
t=2 Writer B: cache ← 12
t=3 Writer A: cache ← 10 ← arrived late
Cache is now 10. ✗ WRONG
Both writes reached the database in order. The two cache writes did not — network timing, thread scheduling, a garbage-collection pause. The cache now serves 10 until its TTL runs out.
A delete cannot go wrong that way, because it carries no value. Whatever order two deletes land in, the result is the same: no cached copy, and the next read takes the truth from the source.
This is write-around from Lesson 9, seen from the invalidation side. Write-through is exempt: there the cache owns the write path, so it decides the order both systems see.
Versioned keys
A different approach: never delete anything. Change the name instead.
When a price changes you leave the cached copy exactly where it is — and start reading from a different key. The version lives in a small key of its own:
product:42:version = 7
Reading a price becomes two steps:
1. read product:42:version → 7 2. read product:42:price:v7 → £25.00
When the price changes, the writer updates the database and increments the version:
product:42:version = 8
The next reader takes the same two steps and lands somewhere else:
1. read product:42:version → 8 2. read product:42:price:v8 → miss → loads £20.00 from the database
Nothing was deleted. product:42:price:v7 is still
sitting in the cache holding £25.00 — but nobody will ever ask for
it again, and it expires on its own.
“Haven't you just added a lookup?” Yes — one, on a tiny key that is read constantly and is therefore always hot. In exchange you never issue a delete, and one version can retire any number of keys at once.
Often you do not need a counter at all. Anything that already changes when the data changes will do:
product:42:price:v<updated_at> product:42:price:v<row_version>
Now the version is a fact the row already carries, and there is nothing extra to maintain. This is the common case for caching anything derived from a row you have already loaded — a rendered HTML fragment, a computed shipping estimate, a formatted price.
How the version reaches the cache in a real applicationoptional read
The reassuring part: the version never leaves the cache layer. The browser does not know about it, the controller does not know about it, and the database does not know about it.
Browser GET /product/42 ← no version anywhere
↓
Controller ProductController.Get(42) ← no version here either
↓
Service GetPriceAsync(42)
│
│ 1. read product:42:version → 7
│ 2. build product:42:price:v7
│ 3. read that key → £25.00 (hit)
↓
Response £25.00
The URL is /product/42 before and after a price
change. Nothing in the request changes. Steps 1–3 happen
entirely inside your service.
| Layer | Knows about the version? |
|---|---|
| Browser | No. Always requests /product/42 |
| Controller | No. Calls GetPriceAsync(42) |
| Service / cache layer | Yes — the only place it exists |
| Database | No. Queried normally on a miss |
Because it is a private detail of one layer, nothing above or below it has to change to adopt it.
public async Task<decimal> GetPriceAsync(int id)
{
// 1. the version pointer — a tiny key, read constantly, always hot
var version = await _cache.StringGetAsync($"product:{id}:version") ?? 1;
// 2. build the real key from it
var key = $"product:{id}:price:v{version}";
// 3. ordinary cache-aside from here on
var cached = await _cache.StringGetAsync(key);
if (cached.HasValue) return (decimal)cached;
var price = await _repo.GetPriceAsync(id);
await _cache.StringSetAsync(key, price, TimeSpan.FromMinutes(30));
return price;
}
And the write:
public async Task UpdatePriceAsync(int id, decimal price)
{
await _repo.UpdatePriceAsync(id, price); // database first
await _cache.StringIncrementAsync($"product:{id}:version"); // 7 → 8
}
That INCR is the whole invalidation. No delete is
issued, no message is broadcast, and no other server needs
telling — they all read the same pointer on their next request
and start building v8 keys by themselves.
Why this matters with many servers Delete-on-write has to reach ten local caches on ten machines. A version bump reaches them for free, because they look the version up rather than being told about it. If the pointer key is ever lost — a cache restart, say — every key simply looks new, which is a cold cache rather than a wrong answer. It fails safe.
Retiring many keys at once
Put the version in a prefix rather than a suffix and it covers everything beneath it:
catalog:v3:product:42 catalog:v3:product:99 catalog:v3:category:8
Increment that single version to v4 and every catalog
key becomes unreachable in one operation. No message has to reach
every cache node, and no key has to be found and deleted.
Why this is powerful:
- No invalidation race. Nothing is being deleted, so nothing can be deleted at the wrong moment.
- No purge cost. No message needs to reach every cache node.
- It works in realms you cannot reach. You cannot delete anything from a user's browser cache — but you can serve a page referencing a new name, and the old entry becomes irrelevant. This is the only mechanism that works for the browser realm.
The costs: you must track the current version and get it to everyone who builds keys. Old entries occupy memory until they expire, so you are paying for garbage for a while.
You have now seen this three times: fingerprinted filenames in Lesson 3, versioned URLs in Lesson 4, versioned keys here. It is the same technique, and it is the strongest tool in this lesson.
Same idea, but do not conflate them — they act at different layers and reach different people.
| Fingerprinting (Lesson 3) | Versioned keys (here) | |
|---|---|---|
| What changes | The URL | An internal cache key |
| Who sees it | The browser, the CDN, everyone | Only your server |
| How the new name arrives | The HTML references it | The reader looks up the pointer |
| Reaches a user already holding a copy | Yes — the old URL is never requested again | No — their browser copy is untouched |
| Retires | One file | One key, or a whole namespace |
The practical consequence: bumping an internal version fixes every
copy on your servers instantly, and does nothing whatsoever
for the copy already sitting on a reader's disk. If a corrected price
has to reach a browser sooner, that is a lifetime decision — the
max-age you chose in Lesson 3 — not a versioning one.
Fingerprinting changes what the reader asks for. Versioned keys change what you look under. Only the first one can reach a copy you have already given away.
Invalidating across all four realms
Here is where the whole course converges. A price changes. Where does the old value live?
| Realm | How you invalidate | How fast | Can you force it? |
|---|---|---|---|
| Browser | Not possible — TTL expiry or a versioned URL | Up to the full TTL | No |
| CDN | Purge, or a versioned URL | Seconds to minutes | Yes |
| Local caches | Not reachable from another process — TTL expiry only | Up to the local TTL | No |
| Redis | Delete the key | Immediate | Yes |
| Database | Automatic | — | Not your concern |
Read the first row again, because it governs your whole design:
This is the reason the storefront's price header in Lesson 3 was
max-age=60 and not max-age=3600. Your
browser TTL is not a performance setting —
it is a hard limit on how fast a correction can possibly reach
your users.
Set it to the longest wrong-answer window you can defend.
And it is the reason versioned URLs matter so much: they are the only way to make a change reach a browser instantly, because they do not require reaching the browser at all.
The repopulation race
One more trap, and it defeats people who have done everything else correctly.
t=0 Reader: cache miss → begins reading database → gets 10
t=1 Writer: database ← 12
t=2 Writer: cache.delete("price") ← correctly invalidated
t=3 Reader: cache.set("price", 10) ← writes the stale value it
fetched at t=0
The cache now holds 10, was correctly invalidated, and will serve the wrong value until its TTL expires. Every step followed the rules. The reader was simply holding a stale value in flight when the delete happened.
The reader's two steps — read the database, then write the cache — have to be atomic with respect to writers. Each step is already atomic on its own; the gap between them is the problem. Close it by holding a lock across both, or by making the cache write conditional so it fails if the key changed underneath it.
Versioned keys sidestep it entirely. The in-flight
reader writes to v7 while everyone has moved on to
v8, so its stale value lands where nobody is looking.
And where neither is in place, a TTL still bounds how long the
damage lasts.
TTL as the safety net
Every technique in this lesson can fail. Messages get dropped. Services restart mid-broadcast. Somebody adds a write path and forgets the invalidation. The repopulation race happens.
TTL is what stops any of those becoming permanent.
Put a TTL on everything. Invalidate explicitly for speed. Let TTL be the guarantee that a failure is temporary rather than forever.
That sentence is the practical summary of this lesson.
At the Storefront
Trace a price change from £25 to £20:
Admin saves £20
↓
Database updated ← truth, immediately
↓
Redis: DELETE price:42:GBP:retail ← immediate
↓
CDN: purge /product/42 ← seconds
↓
Browsers: no direct mechanism ← up to 10s, by your own TTL
The browser may reuse a price response for up to 10 seconds. Monitor CDN purges and invalidation delivery too, because their timing determines when the other copies change.
That single trace is what Lesson 14 asks you to produce for every mutable element.
“I'll just update the cache when I write.”
Two concurrent writes can reach the cache in a different order from the database, leaving the wrong value until it expires. Deleting the cached value avoids this ordering problem, though the next read will miss.