Interlude · Spirit
The Spirit Library
You have walked all four realms. You have seen how each one behaves, and you may have noticed something: the same shapes keep appearing. The edge fetching from origin on a miss. The browser serving a stale copy while fetching a fresh one. Two servers holding different answers.
These are not four separate arts. They are one art wearing four faces.
The Spirit Library sits outside the four realms and belongs to none of them. What is written here works everywhere — which is why you learn it once, not four times.
The Three Reading Techniques
The question
Every read has the same shape: the data might be in the cache, or it might not. What happens on a miss?
There are three answers, and the difference between the first two is who is responsible.
Cache-aside
Cache-aside — also called lazy loading — means your application manages the cache directly. It is the most common pattern in use, and the default you should assume unless there is a reason otherwise.
value = cache.get(key)
if value is not None:
return value # HIT
value = database.query(key) # MISS — application fetches
cache.set(key, value, ttl=300) # application stores
return value
The application talks to both the cache and the database, and it owns every step.
Why people use it:
- Only requested data is cached. Nothing is stored that nobody wants.
- If the cache is unavailable, the app can fall back to the database, as long as the database can handle the extra load.
- It works with any cache and any database. No special library required.
- You can see exactly what is happening.
The costs:
- Every miss pays three trips: check cache, query database, write cache.
- The logic is repeated everywhere you cache something, and someone will eventually get it wrong.
- It has a specific race condition, which is the second half of this lesson.
Read-through
Read-through means your application only ever talks to the cache. The cache knows how to load missing data by itself.
value = cache.get(key) # cache handles the miss internally return value
You configure the cache with a loader function — the code that fetches from the database — and the cache calls it when it needs to.
The distinction is exactly this:
| Who handles the miss | |
|---|---|
| Cache-aside | Your application |
| Read-through | The cache layer |
That is the whole difference. Not performance, not freshness — responsibility. And because it decides where the logic lives, it decides where the bugs live.
Why people use it: the loading logic exists once, not scattered everywhere. Application code becomes simple. Many caching libraries do the hard parts (like the race below) for you.
The costs: you need a library that supports it. The cache going down is now harder to survive, because your app has no other path to the data. And the loading logic is one step further from the code that uses it.
You have already met one. In Lesson 4, the CDN fetched from origin on a miss without your application knowing. A CDN is a read-through cache. You did not implement it, so it did not feel like a pattern. It was.
Refresh-ahead
Both patterns above are reactive: a user hits a miss, and that user waits while the data loads.
Refresh-ahead is proactive. For values you know are hot, refresh them before they expire, so no user ever encounters the miss.
TTL = 300 seconds, refresh at 240
t=0 value cached
t=240 ─→ background job refreshes it (no user is waiting)
t=245 value replaced, timer resets
...no user ever sees a miss on this key
Why people use it: for hot keys, misses effectively disappear. Your p99 latency — the slow tail you will meet in Lesson 13 — improves dramatically, because the tail was the misses.
The costs: you refresh values that may never be requested again, so you do work for nothing. You need to identify which keys deserve it. Applying it to everything is a way to generate constant pointless load.
You have seen a related idea in Lesson 3.
stale-while-revalidate serves a stale copy while fetching a
fresh one. Refresh-ahead starts before the copy expires.
Which realm uses which
| Realm | Natural pattern | Why |
|---|---|---|
| Browser | Cache-first with revalidation | Controlled by headers, not code |
| CDN | Read-through | Fetches from origin on a miss automatically |
| Local cache | Cache-aside or memoization | You wrote it, you own it |
| Redis | Cache-aside | The usual default |
| Database buffer pool | Read-through | The database handles its own misses |
Notice the pattern: when you own the code, you get cache-aside. When someone else built the cache, you get read-through. The techniques are the same everywhere; only the ownership moves.
The race: many readers, one missing key
Here is the flaw in cache-aside, and it is not a rare edge case — it is a guaranteed event on any popular key.
The front-page product is requested 1,000 times per second. Its cached value expires.
t=0.000 TTL expires
t=0.001 Request 1 → miss → queries database
t=0.001 Request 2 → miss → queries database
t=0.001 Request 3 → miss → queries database
...
t=0.001 Request 1000 → miss → queries database
t=0.050 first query returns, cache is written
— 49 milliseconds too late for the other 999
The cache did not fail. Every one of those requests followed cache-aside correctly. The pattern has a gap between “the value expired” and “the value has been reloaded,” and every request arriving in that gap goes to the database.
Worse, this is self-amplifying. A thousand simultaneous queries slow the database, which makes the reload take longer, which widens the gap, which lets more requests through.
Single-flight
Request coalescing — usually called single-flight — fixes it: when many requests need the same missing key, one of them fetches it, and the rest wait for that one result.
t=0.001 Request 1 → miss → acquires the loader slot → queries database t=0.001 Request 2 → miss → someone is loading → waits t=0.001 Request 3 → miss → someone is loading → waits ... t=0.050 Request 1 gets the value, stores it, and hands it to all 999 waiters
One database query. All 1,000 requests served.
The shape:
def get(key):
value = cache.get(key)
if value is not None:
return value
# only one caller per key gets past this point;
# the others block here and receive the same result
with single_flight(key):
value = cache.get(key) # check again — someone may have
if value is not None: # filled it while we waited
return value
value = database.query(key)
cache.set(key, value, ttl=300)
return value
Two details that matter:
-
The second
cache.getis not redundant. By the time a waiter is released, the value usually exists. Checking again avoids a pointless second query. This is the double-checked pattern. - The lock is per key, not global. A global lock would make every miss on every key wait behind every other, which would be far worse than the problem.
Most mature caching libraries provide this. It is worth knowing it exists before you need it — and you are about to need it.
Remember this. This is your first weapon against the Latency Lord, and it is deliberately being handed to you four lessons before the fight.
At the Storefront
Cache-aside for product details, with single-flight — it is the front page item and it will stampede.
Refresh-ahead for the category tree — small, hot, expensive, on every page.
Nothing for the cart badge, in any pattern.
“Cache-aside and read-through are the same thing.”
They differ in who owns the miss, and that decides who owns the race condition above. With cache-aside, it is yours to solve. With read-through, your library probably solved it — which is a genuine reason to prefer one.
The Latency Lord has been watching the reading techniques, and he has found the gap.
Not a flaw in the cache. A gap in time — the space between the moment a value expires and the moment it is restored. It is small. Fifty milliseconds, perhaps.
A thousand requests can arrive in fifty milliseconds.
He does not need to take the cache away. He only needs to choose the moment it empties.
The Three Writing Techniques
The problem
Reading was straightforward: the value is there or it is not.
Writing is harder, because now two places hold the same fact — the cache and the database — and a write must deal with both. Between the moment you update one and the moment you update the other, they disagree.
There are three strategies. They differ in when the database is written and whether the cache is written at all.
Write-through
Write-through means the cache owns the write. Your application writes to the cache, the cache writes to the database, and the call does not return until the database write has completed.
Both writes happen — but one after the other, not at the same time. The caller waits for the whole chain.
Application → cache.put(key, value)
↓
cache writes to the database
↓
database confirms
↓
Application ← success
Note who is doing the work: the cache talks to the database, not your application. That is what makes it through — and it is the same distinction that separates read-through from cache-aside.
Implementations differ on the order. Some write the cache first, then the database. Oracle's Coherence writes the database first and updates the cache only once that has succeeded, so that a failed database write can never leave a value sitting in the cache that the database has never seen.
Why people use it: the cache is never behind the database, because success is not reported until the database has the write. A subsequent read is correct. Reasoning about the system is simple.
The costs:
- Writes are slower. You pay for both systems on every write.
- You cache things nobody reads. Every write populates the cache, whether or not that item will ever be requested. On write-heavy data with few reads, you fill your cache with dead weight and evict things people actually want.
- It is not actually atomic. Writing to two systems without a transaction spanning both means one can succeed and the other fail. If the cache write succeeds and the database write fails, your cache now holds a value that does not exist in the source of truth — which is worse than being stale.
Where you would use it
- Account and profile settings. Someone changes their display name and lands straight back on a page showing it. The cache is already correct when that page renders.
- Feature flags and configuration. Written rarely, read on nearly every request, and every server needs the new value at once.
- Stock counts on a low-volume catalogue. Being wrong means overselling, and the write rate is low enough that the extra latency does not matter.
- Shopping cart contents. Written, then immediately read on the next page, every time.
The pattern: read-after-write is common, and being wrong is worse than being slow.
Write-behind
Write-behind — also called write-back — writes to the cache, returns success immediately, and writes to the database later in the background.
Application → write to cache → return success ← user is done here
↓
(later) write to database
Why people use it:
- Writes are very fast. The user waits for a memory write, not a disk write.
- Writes can be batched. A counter updated 1,000 times in a second becomes one database write of the final value. For write-heavy workloads this is a genuine order-of-magnitude reduction in database load.
- Sudden write spikes get absorbed instead of overwhelming the database.
The cost, stated plainly: you can lose data.
If the cache dies between the acknowledgement and the database write, that write is gone. You told the user it succeeded. It did not.
This is not a rare edge case to design around — it is the fundamental trade. Write-behind is correct for data where losing some is acceptable. It is wrong for orders, payments, and inventory.
Where you would use it
- View counts. A page viewed a million times an hour becomes a handful of database writes instead of a million.
- “Last seen” and “last active” timestamps. Updated on every single request, and nobody notices if one is a minute stale or lost entirely.
- Likes, reactions, and vote tallies. High volume, and an occasional lost increment is invisible.
- Analytics and telemetry events. Losing a fraction of a percent does not change any dashboard anyone looks at.
The pattern: enormous write volume, and nobody files a support ticket if a few writes vanish.
The test to apply: if thirty seconds of these writes disappeared in a crash, would anyone notice? If the answer is yes, this is not your strategy.
Write-around
Write-around writes only to the database and does not populate the cache at all. Usually it also removes any existing cached copy, so a stale one cannot survive.
Application → write to database → return success
(cache entry deleted if present)
next read misses and loads fresh
Why people use it: write-heavy data that is rarely read soon after writing does not pollute the cache. Your cache stays full of things people are actually reading.
The cost: the next read of that item is a miss. If your write pattern is “write then immediately read” — which is extremely common, since users usually look at what they just did — write-around gives you a guaranteed miss on every one.
Where you would use it
- Bulk imports and migrations. Loading a million rows overnight. Filling the cache with all of them would evict everything real users actually want — the exact scan problem you will meet in Lesson 10.
- Audit logs and event records. Written constantly, read almost never, and usually only during an investigation.
- Historical and archival data. An order from three years ago is written once and may never be read again.
- Draft autosave. A user saves every few seconds; only the final version is ever read by anybody else.
- Sensor and IoT readings. High write volume, queried later in aggregate rather than individually.
The pattern: written far more often than read, or not read again for a long time.
Choosing
| Situation | Strategy | Because |
|---|---|---|
| Read soon after write, must be correct | Write-through | Cache is right immediately |
| Very high write rate, some loss acceptable | Write-behind | Speed and batching |
| Written often, read rarely | Write-around | Keeps the cache useful |
| Read-heavy, occasionally written | Write-around + cache-aside | Simple, safe, and the common default |
That last row deserves attention. Write-around paired with cache-aside — write to the database, delete the cached copy, let the next read reload it — is the most widely used combination in practice, because it is simple and it fails safely. It is also, under a different name, the subject of Lesson 11.
At the Storefront
Stock count — decrements constantly, and being wrong means selling something you do not have. Write-through keeps the cache from showing more stock than exists, but note that a write strategy alone does not make a decrement safe: two servers can both read 5, both subtract 1, and both write 4. That is the read-modify-write race from Lesson 6, and it needs an atomic decrement rather than a write pattern. Not caching the stock count at all is a legitimate answer here.
Review submission — written rarely, read constantly. Write-around: store it, drop the cached review list, let the next reader rebuild it.
Product view counter — written on every page view, read almost never, and losing a few is fine. Write-behind, batched.
Three write paths, three different strategies, on one page.
“Write-behind is just a faster write-through.”
It trades durability for speed. If the cache dies before the flush, the write is gone — after you told the user it succeeded. Choose it deliberately, for data you can afford to lose.
The Temple of Eviction
Two different reasons data leaves
Things disappear from a cache for two reasons, and the two have nothing to do with each other.
- Expiration — the copy got old, so you stopped trusting it.
- Eviction — you ran out of room, so something had to go.
That is the whole distinction. It is worth stating that plainly, because the two are confused constantly — including in documentation.
| Trigger | Reason | Controlled by | |
|---|---|---|---|
| Expiration | Time | The copy is too old to trust | TTL |
| Eviction | Memory pressure | You ran out of room | Eviction policy |
Expiration is time-driven. It is about correctness. The value may still be perfectly useful and there may be plenty of memory — you remove it because you no longer believe it.
Eviction is memory-driven. It is about capacity. The value might be perfectly current and useful — you remove it because something else needs the space.
A cache with infinite memory still needs expiration. A cache holding data that never changes still needs eviction. They are independent, and confusing them leads to setting TTLs to manage memory (which makes your data needlessly stale) or sizing memory to manage freshness (which does not work at all).
TTL, used deliberately
You met TTL in Lesson 2. Now use it for two distinct jobs.
TTL as a correctness tool. The TTL is your
statement about how wrong you are willing to be.
TTL = 300 says:
I accept this may be up to five minutes out of date. Pick
it from the business requirement, not from habit.
TTL as a safety net. This one is subtle and important. Even with perfect invalidation (Lesson 11), things go wrong — a message is dropped, a service is down, a code path is missed. TTL bounds the damage. Without it, a failed invalidation means a value is wrong forever. With it, the worst case is bounded by the TTL.
Put a TTL on everything, even data you invalidate explicitly. It is the backstop for the day your invalidation silently fails. It will.
TTL jitter
Set 10,000 keys during a deployment, all with
TTL = 3600.
One hour later, all 10,000 expire in the same second.
Jitter is adding a small random amount to each TTL so they spread out:
ttl = 3600 + random(0, 600) # between 60 and 70 minutes
Now those 10,000 keys expire spread across ten minutes rather than in a single instant. This single line prevents a specific attack in Lesson 12, and it costs you nothing.
Eviction policies
When the cache is full and something new arrives, something old must go. The eviction policy decides what.
LRU — Least Recently Usedoptional read
Remove whatever has not been touched for the longest time.
The idea is simple: recently used items are often used again soon. That makes LRU a useful starting point.
The weakness — and it is severe. LRU is destroyed by a scan: one large pass over data that will not be requested again. A nightly report reading a million products marks all of them “recently used,” pushing out the small hot set that your actual users depend on. The report finishes; your cache is full of things nobody wants; your hit ratio collapses; your database absorbs the difference.
A single batch job can wipe out a perfectly healthy cache. Watch for this.
Where you would use it Almost
everywhere, as the default. It is what Redis
allkeys-lru gives you, what most application cache
libraries do without being asked, and what your CPU does with
its own caches. Session data fits it especially well — a user
who was active a minute ago is the one most likely to be back.
Reach for something else only if you have batch jobs sweeping
the cache, or a hot set stable enough for LFU to protect.
LFU — Least Frequently Usedoptional read
Remove whatever has been requested least often, regardless of when.
The logic: popularity is more meaningful than recency. A page requested 10,000 times matters more than one requested once, even if the once was more recent.
Why it can beat LRU: it survives scans. The report's million products each get one access; the front-page item has thousands. LFU keeps the front page and discards the report data — exactly right.
The weaknesses:
- New items look unpopular. Something added a second ago has a count of 1 and is a prime eviction target, even if it is about to become the most popular item on the site. New content struggles to establish itself.
- Old items overstay. Something enormously popular last month keeps a high count long after people stopped wanting it.
Practical implementations use windowed or decaying LFU, counting recent accesses more heavily, which fixes both. If you choose LFU, choose one of these.
Where you would use it When a small, stable set of items takes most of the traffic — the top 1% of products that account for most views, a CDN holding assets that stay popular for months, reference data that barely changes. It is also the right answer when you have a nightly batch job that would otherwise flush an LRU cache. Avoid it where content turns over quickly: news, social feeds, anything where today's popular item was created this morning.
FIFO — First In, First Outoptional read
Remove whatever was added earliest, regardless of use.
Simple and cheap — no bookkeeping. It also ignores usage entirely, so it will happily evict your most popular item because it happened to be cached first. Rarely the right choice for a cache; fine for a queue.
Where you would use it Honestly, rarely, and almost never for a general cache. Where it does fit is anywhere insertion order is the point rather than an accident — a fixed-size buffer of recent events, a replay window, a rolling log kept in memory. If every entry has roughly equal value and you want zero bookkeeping, FIFO is defensible. If some entries are more valuable than others, it is the wrong tool.
Randomoptional read
Remove a randomly chosen item.
This sounds like giving up, and it is not. It requires no bookkeeping at all, which makes it very fast and memory-efficient — LRU must maintain ordering on every single access, and that costs something on every read.
For workloads without a strong recency pattern, random performs surprisingly close to LRU at a fraction of the overhead. Redis offers it deliberately. Do not dismiss it.
Where you would use it Very
high-throughput caches where LRU's per-read bookkeeping is a
measurable cost, and memory-constrained systems where the
ordering structure itself is too expensive to keep. It also does
well where access is genuinely uniform and no hot set exists to
protect. Redis exposes it as allkeys-random for
exactly these cases.
Choosing
| Policy | Best when | Fails when |
|---|---|---|
| LRU | Access is bursty and recency-driven | A scan sweeps through it |
| LFU | There is a stable hot set | Content turns over quickly |
| FIFO | Almost never for caches | Any time popularity matters |
| Random | Low overhead matters; access is uniform | A clear hot set deserves protecting |
Real caches offer combinations — Redis has allkeys-lru,
volatile-lru (only keys with a TTL set),
allkeys-lfu, volatile-ttl (shortest
remaining life first), and noeviction (reject writes
when full).
volatile-* variants matter more than they look: they
let you mark some entries as never-evictable by giving them no TTL,
protecting data you cannot afford to lose.
Reading the eviction rate
The eviction rate — how many items are being removed per second for lack of space — is one of the most useful signals a cache gives you.
- Near zero: your cache is big enough. Good.
- High and rising: your cache is too small. Items are being thrown out before they can be reused, and your hit ratio is being destroyed by capacity, not by TTL.
- Sudden spike: something just flooded the cache. A batch job, a scan, a bug writing far too much.
A high eviction rate paired with a falling hit ratio is a specific, actionable diagnosis: the cache needs more memory, or you are caching too much. Lesson 13 will show you where to watch this.
At the Storefront
| Part | TTL | Reasoning |
|---|---|---|
| Product details | 1 hour + jitter | Changes rarely; an hour stale is harmless |
| Price | 60 seconds | Must be close to correct |
| Stock count | 10 seconds, or uncached | Being wrong here sells things you do not have |
| Reviews | 10 minutes + jitter | Slow-changing, heavily read |
| Category tree | 5 minutes | Small, hot, rarely edited |
Policy for the Redis instance:
allkeys-lru as the default — and check
for batch jobs that scan products, because those will wreck it. If
you have one, either route it around the cache or move to windowed
LFU.
“LRU is always the right choice.”
LRU is a good default that a single batch job can destroy. LFU survives scans but starves new content. Random is cheaper than both and often nearly as good. Choose from your access pattern, not from habit.
The Latency Lord walks the realm at a quiet hour and does something very simple.
He does not delete anything. He does not break anything. He simply finds every expiry timer in the kingdom and sets them all to the same hour.
Nothing is stolen. Nothing is broken. Everything simply ends at once.
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.