The Last CachebenderInterlude · Spirit
Interlude · Spirit
Interlude · Spirit · Lesson 11 of 14
Lesson Eleven

The Invalidation Paradox

Technique unlocked The Truthbinding keeping the copy true to the source

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.

When your application owns both writes, delete on write — do not update on write. It costs one extra miss and removes an entire category of bug that is nearly impossible to reproduce.

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:

You cannot reach the browser cache. Once you have told a browser to keep something for an hour, it is gone for an hour. There is no recall.

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.

Common misconception

“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.