The Last CachebenderInterlude · Spirit
Interlude · Spirit
Interlude

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.

Lesson Eight

The Three Reading Techniques

Technique unlocked The Three Draws cache-aside, read-through, refresh-ahead

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
Application Cache Database 1 get miss 2 query — straight past the cache row 3 set
Three calls, all of them made by the application

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.

Application Cache Database 1 get 2 load row value
The application never reaches past the cache
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.

t = 0 cached t = 240 refreshed t = 300 would expire background refresh — nobody is waiting
The value is replaced before anyone meets a miss

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
One thousand identical database queries, for one value.

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.get is 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.

Common misconception

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

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.

Lesson Nine

The Three Writing Techniques

Technique unlocked The Three Seals write-through, write-behind, write-around

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 Database write write success — only after both have been written
Nothing succeeds until both are written
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 Cache Database write success, now later lost if the cache dies first
Success is reported before the database is written
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-behind is not a faster write-through. It is a different guarantee. Write-through promises durability. Write-behind promises speed, and explicitly does not promise durability.

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 Cache Database write delete the copy the next read misses, and reloads it
Write to the database, then remove any cached copy.
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.

Common misconception

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

Lesson Ten

The Temple of Eviction

Technique unlocked The Letting Go expiry and eviction

Two different reasons data leaves

Things disappear from a cache for two reasons, and the two have nothing to do with each other.

  1. Expiration — the copy got old, so you stopped trusting it.
  2. 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.

Reference — expand as needed
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.

Common misconception

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

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.

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.

The Library is closed Next · Book Five: The Great War →