The Last CachebenderBook Five · War
Book Five · War
Book Five

The Great War

The Surge arrives at midnight.

The Latency Lord comes with five weapons, and you have watched him forge every one. He built them out in the open, in front of you, while you were learning.

Bring your earlier techniques together, and learn when a Bloom filter can help turn away requests for nonexistent products.

Lesson Twelve

The Latency Lord's Attacks

The trial Put your cache defenses to the test.

How this lesson works

Use the earlier lessons to work through five attacks. For requests that name nonexistent products, you will also learn an extra defense: Bloom filters.

For each attack:

  1. The attack — watch it break the storefront.
  2. Your attempt — before reading on, use what you already know to propose a defense.
  3. The confirmation — compare your answer with the defense and follow links to earlier lessons when you need a refresher.

Most defenses build on Lessons 1–11. The Bloom-filter section explains a new tool, including when it helps and how to avoid rejecting real products.

Attack 1 — The Stampede

Midnight. The front-page product's cached value expires. In the next 50 milliseconds, 1,000 requests arrive. Every one misses. Every one queries the database.

The database, which was comfortable, receives 1,000 identical queries at once. It slows down. Because it slows down, the reload takes longer. Because the reload takes longer, more requests pile into the gap. Because more requests pile in, the database slows further.

Your database falls over. Not because of a flaw in your cache — because your cache worked exactly as designed. Cache-aside has a gap between “expired” and “reloaded,” and every request arriving in that gap goes to the database.

Also called a dogpile or thundering herd.

You saw this being forged · Lesson 8

He does not need to take the cache away. He only needs to choose the moment it empties.

Stop here and work out the defense before reading on.
TechniqueFromWhat it does
Single-flightL8One request loads; the other 999 wait for its answer. 1,000 queries become 1.
Refresh-aheadL8Refresh hot keys before expiry — the gap never opens.
TTL jitterL10Different keys expire at different moments, so stampedes never coincide.
Serve stale while refreshingL3Hand out the old value instantly, refresh behind it. Nobody waits, nobody misses.
Origin shieldL4The CDN version: one edge absorbs the miss on behalf of all the others.

Single-flight is the direct answer. Refresh-ahead is the preventive one. Use both on your hottest keys.

Attack 2 — The Penetration

Requests arrive for product:999999999. It does not exist and never has.

Follow it through your realms. The browser has no copy — nothing was ever stored. The CDN has no copy. Redis has no copy. So the request reaches the database, which searches its index and confirms the product does not exist.

Then the same request arrives again. Every step repeats. This implementation caches found products, but forgets missing ones.

Your entire caching architecture is transparent to these requests. Every one goes straight to the database, and an attacker can send thousands per second using randomly generated IDs. Your cache defends nothing, because there is nothing to cache.

You saw this being forged · Lesson 2

He begins writing down names of things that were never real.

Stop here and work out the defense before reading on.

Negative caching — cache the fact that something does not exist.

key = "product:999999999"
value = cache.get(key)

if value is NOT_FOUND_MARKER:
    return 404                          # HIT — never touches the database

if value is None:                       # genuinely not in cache
    row = database.query(...)
    if row is None:
        cache.set(key, NOT_FOUND_MARKER, ttl=60)   # ← cache the absence
        return 404
    cache.set(key, row, ttl=300)
    return row

return value                           # ordinary cache hit

Where this comes from: L2 taught that a key maps to a value, and L8 taught cache-aside. The insight is that “there is no such product” is a perfectly valid answer, and valid answers can be cached. Nothing new — just noticing that a negative result is still a result.

Two details:

  • Use a short TTL. For this example, cache confirmed absence for 60 seconds. If the product is created, clear its negative entry; the TTL is a fallback if that invalidation is missed.
  • Use a distinct marker. You must be able to tell “cached as non-existent” apart from “not in the cache.” Storing plain null usually makes those indistinguishable, and you get a permanent miss instead of a hit.

Also worth applying: input validation. If product IDs are always under 8 digits, reject 999999999 before it reaches any cache or database at all. The cheapest defense is not answering the question.

Bloom filters: checking whether an ID could exist

Negative caching helps when the same missing ID is requested again. If each request uses a different fake ID, the first lookup still reaches the database. A Bloom filter can reject many of those requests before that lookup.

Think of it as a compact checklist of product IDs. It stores a pattern of bits, not product records. To add an ID, several hash functions pick positions in the bit array and set them to 1. To check an ID, the filter examines those same positions.

Definitely absentAt least one bit is 0. This ID was never added to this filter.
Possibly presentAll the bits are 1. Continue the lookup to find out whether the product exists.

Different IDs can set the same bits. That overlap can produce a false positive: an ID looks familiar even though it was never added. An intact Bloom filter has no false negatives for IDs successfully added to it. That guarantee only covers the filter's contents; a new product missing from the filter could still be rejected by the application.

product 42→ bits 1, 4 and 6 are set

If another ID also maps to bits 1, 4 and 6, the answer is “possibly present.” If one of its positions is still 0, the answer is “definitely absent.” A positive answer never proves that a product exists.

Where it fits

Validate the ID, then check the cache. On a cache miss, consult a filter built from all published product IDs, including products whose cache entries have expired. A definite absence can return 404; a possible match continues to the database.

# After validation and a cache miss.
# Application helper, not a raw Redis command:
verdict = product_filter.check_if_current(product_id)

if verdict == DEFINITELY_ABSENT:
    return 404

# POSSIBLY_PRESENT or UNKNOWN: use the normal lookup.
row = database.find_product(product_id)
if row is None:
    cache.set(key, NOT_FOUND_MARKER, ttl=60)
    return 404
cache.set(key, row, ttl=300)
return row

The helper returns UNKNOWN if the filter is missing, rebuilding, unavailable, or not known to cover the current catalogue. It must check readiness and membership against the same filter version; a separate existence check can race with deletion or replacement. A database error is also an error, not proof of absence; never cache it as a 404.

Keep the checklist safe to use

  • Load it before trusting it. Build from the catalogue and include writes made during the build before enabling rejection. Checking a newly created, empty filter would hide real products.
  • Add new products before publishing them. Every filter used to reject requests must include the ID before the product becomes readable. If that cannot be guaranteed, bypass rejection until the filter catches up. Clear any negative cache entry for the new product too; a short TTL only limits how long an old 404 can last.
  • Do not clear bits to delete an ID. Other products may share them. Keep deleted IDs until a safe rebuild; they cause extra lookups, which the database can resolve.
  • Plan for growth and failure. Size the filter for the expected catalogue and a chosen false-positive rate. Monitor capacity and rebuild or expand as needed. Keep rate limits and database concurrency limits in place when the filter is bypassed.

Use this extra check when requests for many different missing IDs are a measured problem. It reduces unnecessary lookups; it does not stop every attack. False positives, deleted products, and requests for real products can still reach the database. Negative caching handles repeated confirmed misses, while rate limits bound incoming traffic.

Redis exampleBF.EXISTS returns 0 both when an item is absent and when the filter key does not exist. A missing filter must trigger fallback, not a blanket 404. Read more in the Redis Bloom-filter guide.

Attack 3 — The Avalanche

You deployed at 14:00 and warmed the cache — 50,000 keys, all written within a few seconds, all with TTL = 3600.

At 15:00, all 50,000 expire in the same second.

Every request now misses. Not one hot key — everything, simultaneously. Your database receives your entire read traffic with no cache in front of it, for as long as it takes to reload 50,000 entries. It cannot cope, because it was never sized to; the cache was absorbing 95% of the load.

The same thing happens when a cache node restarts, or when a cluster fails over and comes back empty.

The distinction from a stampede: a stampede is many requests, one key. An avalanche is many keys, all at once.

You saw this being forged · Lesson 10

He simply finds every expiry timer in the kingdom and sets them all to the same hour.

Stop here and work out the defense before reading on.
TechniqueFromWhat it does
TTL jitterL10ttl = 3600 + random(0, 600) spreads 50,000 expiries across 10 minutes
Tiered TTLsL10Different data types get different lifetimes, so they never align
Cache warmingL2After a restart, reload deliberately rather than under live traffic
Single-flightL8Limits the damage — one query per key, not one per request
Serve stale on failureL3, L11If the database is struggling, keep serving expired values rather than adding load

Jitter is the fix, and it is one line of code. This is the cheapest defense in the entire course, and forgetting it is the most common cause of a self-inflicted outage on this list.

Attack 4 — The Hot Key

You partitioned your cache across 10 nodes. Load is evenly spread — except that one key is not like the others.

The front-page item receives 40% of all cache traffic. So one node receives 40% of your traffic while the other nine share the remaining 60%.

That node saturates. Its latency rises. Every request for the popular item — the item most of your revenue depends on — is now slow. The other nine nodes are nearly idle.

Adding more nodes does not help. The key still lives on exactly one node. Sharding distributes keys; it cannot distribute one key.

You saw this being forged · Lesson 6

He does not need to attack every node in the cluster. He needs the one node that holds that key.

Stop here and work out the defense before reading on.

1. A local shield cache (L5). Keep the hot key in each application server's own memory, in front of the shared cache. Ten app servers each holding a copy means the shared cache receives ten requests instead of a million. A 5-second local TTL is usually plenty. This is the single most effective fix, and it is exactly the point of Lesson 5.

2. Key splitting (L2). Store the same value under several keys and pick one at random per request:

product:42:copy0
product:42:copy1
   ...
product:42:copy9      ← 10 keys, one value, spread across 10 nodes

Reading picks a random copy, so traffic distributes. Writing must update all ten, so this is for read-heavy data only.

3. Replication (L6). Put the hot partition on replicas and spread reads across them.

4. Detection first (L13). You cannot defend a hot key you have not found. Per-key metrics are how you find them — which is next lesson.

This defense combines a local cache with a distributed cache — the two parts of the application realm from Book Three.

Attack 5 — The Poisoning

The worst one, because it is a correctness failure rather than a performance failure — and it can be a security incident.

A logged-in user requests the product page. The response includes their cart badge: “2 items.” Something in your stack caches that response under the key /product/42, without the user in the key.

The next visitor requests /product/42. They get a hit. They see someone else's cart.

That is cache poisoning: incorrect or unauthorised content stored in a cache and served to everyone.

How it happens in practice:

  • A key missing a distinguishing input — the Lesson 2 failure, at scale
  • Vary not set on a response that varies by language, currency, or authentication
  • private not set on a personalised response, so a shared cache stores it
  • An error page cached — a 500 error gets a 1-hour TTL and now everyone sees an error page for an hour
  • Unnormalised request headers — an attacker sends a header your app reflects into the response, and the poisoned version is cached and served to others
You saw this being forged · Lesson 4

He does not need to break into the Edge Kingdom. He only needs to be mistaken for someone else.

Stop here and work out the defense before reading on.
TechniqueFromWhat it does
Key disciplineL2Every input that changes the answer is in the key. Including the user.
private on anything personalL3Shared caches are forbidden from storing it
no-store on anything sensitiveL3Never written to disk anywhere
Vary set correctlyL4Language, currency, encoding declared — and never Vary: Cookie
Key normalisationL4Strip headers and parameters that must not affect the response
Never cache authenticated responses in a shared cacheL3, L4The absolute rule
Do not cache errors — or cache them for secondsL10A cached 500 turns a blip into an outage

The blanket rule worth memorising:

If a response differs per user, it must never be stored in a shared cache. Ever.

That is why the cart badge has been marked “never cached” since the very first table in this course. It was not an arbitrary choice. It was this attack, waiting eleven lessons to arrive.

The pattern worth seeing

Look at the five attacks together:

AttackRoot cause
StampedeYour TTL and the gap after it
PenetrationYour keys — you only cached successes
AvalancheYour TTLs, all set to the same value
Hot keyYour key design and partitioning
PoisoningYour keys — missing an input

Four of the five are caused by your own key and TTL decisions. Only penetration comes from outside, and even that is a decision about what you chose to cache.

The Latency Lord did not break your cache. He used it exactly as you configured it.

Then the question worth carrying out of this lesson: which of the five would your current design already survive, and which would take you down tonight?

Common misconception

“These are exotic problems for systems much bigger than mine.”

Every one of these happens at ordinary scale on an ordinary weekday. Avalanche in particular is usually self-inflicted, by a deploy, on a Tuesday afternoon, with no attacker involved at all.

Lesson 12 complete Next · Restoring Balance →
Lesson Thirteen

Restoring Balance

Technique unlocked The Measure instrumentation, honesty, and restraint

You survived. Now find out what actually happened.

You have four realms of caching, five defenses, and an application that stayed up. You cannot see any of it.

This lesson is about seeing — and about the harder discipline of removing caching that has not earned its place.

Measure hit ratio by key prefix

A global hit ratio is not enough, because it can average away the data you need to investigate.

Global hit ratio: 85%    ← looks healthy

Broken down:
    asset bundles      99.8%     cheap on a miss anyway
    product images     99.1%     cheap on a miss
    product details    94%       moderate
    reviews            78%       moderate
    price               6%       ← expensive, and effectively uncached

That 85% is real, and it is hiding the fact that your most expensive query has essentially no cache at all. The cheap, high-volume things inflated the average until the problem disappeared inside it.

Measure hit ratio by key prefix as well as overall. The breakdown shows which data benefits from caching and which misses are still expensive.

Also, from Lesson 2: the last few percent are worth the most. Going 90% → 99% removes 90% of your remaining misses. Going 50% → 59% removes 18%. Effort spent on an already-good ratio pays more than effort spent on a bad one — provided the traffic is expensive.

Latency percentiles

A percentile tells you what a given fraction of requests experienced.

  • p50 (the median) — half of requests were faster than this
  • p95 — 95% were faster; the slowest 1 in 20 were worse
  • p99 — 99% were faster; the slowest 1 in 100 were worse

Averages hide caching problems, because caches make most requests fast and a few requests slow — which is precisely the shape an average erases.

Average:  12ms      ← "we're fine"
p50:       2ms      ← the hits
p95:      15ms
p99:     450ms      ← the misses, and every user who hit one

Your p50 is your hit path. Your p99 is your miss path. If p50 is excellent and p99 is terrible, your cache is working and your misses are expensive — that is exactly the case for single-flight and refresh-ahead from Lesson 8.

At 1,000 requests per second, the slowest 1% is 10 requests every second. Pages that make many requests have more chances to encounter a slow response.

Memory and eviction rate

From Lesson 10 — the combination is diagnostic:

Hit ratioEviction rateDiagnosis
LowHighCache is too small. Items evicted before reuse.
LowLowTTLs too short, or the data genuinely isn't reused.
HighHighWorking, but under pressure. More memory would help.
HighLowHealthy.

A low hit ratio means very different things depending on the eviction rate, and you cannot tell them apart without both numbers.

Raising a hit ratio deliberately

Four techniques, with honest costs:

1. Adjust key granularity. Caching a whole rendered page gives one entry that must be discarded when anything on it changes. Caching pieces gives more entries, each surviving independently. Finer keys usually mean a higher hit ratio and more lookups.

2. Tune TTLs. Longer TTL, higher hit ratio, more staleness. Pure trade, and the answer comes from the business, not from you. Ask: how wrong can this be?

3. Warm the cache (L2). Attacks the cold-start portion of your misses. Effective after deploys and restarts; does nothing for steady-state misses.

4. Separate hot from cold. If 5% of products get 95% of traffic, cache the hot 5% aggressively — locally, long TTL, refresh-ahead — and barely cache the rest. This usually beats treating everything uniformly, and it is how the hot-key defense in Lesson 12 is built.

And the option people forget: accept the ratio. 60% on data that must be fresh may be the correct answer. Not every number needs improving.

Debugging across four realms

A user reports a stale price. You have four caches. Which one served it?

Without instrumentation this is guesswork. With it, it is a header.

HeaderMeaning
Age: 240This copy has been in a cache for 240 seconds
X-Cache: HIT / MISSCommon CDN convention (non-standard)
Cache-Status: cdn; hit, origin; missThe standard version — reports each cache in the chain

Age is the most useful for stale-data reports. If a user sees a wrong price and Age: 3400, you know it came from a cache with a long TTL and can identify which. If Age: 2, the problem is not staleness — it is your database or your invalidation.

For your own layers, add your own:

X-Cache-Local:  HIT
X-Cache-Redis:  MISS
X-DB-Queries:   3

Not for production users — for you, behind a debug flag, so that “which realm served this?” is a question with an answer.

Also log cache errors separately from misses. They look identical to your application (both mean “go to the database”), and they mean completely different things. A rising cache error rate is a failing cache node. A rising miss rate is a tuning problem. Conflating them hides outages.

Test what happens when the cache dies

The question from Lesson 6, and you should now answer it experimentally.

Turn the cache off in a staging environment under realistic load.

Two possible outcomes:

It gets slower but survives. Good. Your cache is a genuine optimisation.

It falls over. Then what you have is not a cache — it is an undeclared load-bearing dependency with no durability guarantees, and a cache node failure is a full outage.

If the second happens, your options are: size the database for cache-free operation (expensive), add graceful degradation (serve stale, shed non-essential load, queue writes), make the cache highly available, or accept the risk knowingly. All four are legitimate. Not knowing which one you have chosen is not.

What not to cache

The most valuable discipline in the course. Caching is not free — it costs memory, complexity, and correctness. Some things should not be cached at all.

Anything personal, in a shared cache. The cart badge. Lesson 12 attack 5.

Anything genuinely secret. Credentials, tokens, payment details. Not in a shared cache, not on disk, no-store.

Cheap queries. An indexed primary-key lookup served from the buffer pool takes 0.3ms. A Redis lookup takes 0.5ms. You made it slower and added a staleness bug. This is Lesson 7's warning, and it is extremely common.

Data that is written far more than read. Every write invalidates; almost no read benefits. Write-around (L9) and move on.

Data that must be exact. Account balances, remaining stock at the moment of purchase, anything with a legal or financial consequence for being wrong. Read the truth.

Data with no reuse. Caching depends on the same key being asked for again. A per-request unique key is never reused, and caching it is pure cost.

The honest question

For every cache in your system:

What does this layer buy me, and is it worth what it costs?

Cost is memory, code, an invalidation path, a new failure mode, a new way to be wrong, and a permanent increase in how hard your system is to reason about.

Sometimes the answer is a clear yes. Sometimes it is a layer added years ago in front of a query that got an index six months later, and nobody has checked since.

Deleting a cache that no longer earns its place is as much a use of this technique as adding one.

At the Storefront

Instrument all four realms. Then answer, for the last request:

  1. Which realm served it?
  2. How old was the copy?
  3. If it missed, where did the time go?

Then turn off Redis under load and watch what happens.

Common misconception

“A higher hit ratio is always better.”

A 99% hit ratio on data that must be fresh is not a success — it is a bug with excellent metrics. The right target comes from what the data is allowed to be wrong about, not from the number being large.

Lesson 13 complete Next · The Final Battle →
Lesson Fourteen

The Final Battle

Technique unlocked The Convergence all four realms in one design

The task

Design the complete caching strategy for the storefront, across every realm.

Bring together the techniques from the earlier lessons to design one complete caching strategy.

The rubric

#RequirementFrom
1Every one of the seven parts assigned to a realm — or marked never cached, with a reasonL1–L7
2A cache key and a TTL for each cached partL2, L10
3A read strategy and a write strategy for the application layerL8, L9
4An invalidation path for every mutable part, across every realm that holds itL11
5A named defense for each of the five attacksL12
6An instrumentation plan — what you measure, what alerts youL13
7A stated failure mode: what happens when the cache diesL6, L13

A worked example

Here is a worked example for the price, covering each requirement. Use the same structure for the other six parts.

Element: Price

1. Realms — Browser, CDN and Redis, with a 5-second local cache for the most popular products.

2. Keys and TTLs

RealmKeyTTLWhy
BrowserURL + currency + customer tiermax-age=10, must-revalidateA browser cannot be purged, so 10 seconds caps reuse without revalidation
CDNURL + currency + customer tier300s, purged on price changeA purge updates shared copies promptly; alert if it fails
Redisprice:{id}:{currency}:{tier}300s + jitterJitter prevents avalanche (L10)
Local cacheprice:{id}:{currency}:{tier}5sAbsorbs hot-key traffic before it reaches Redis

Currency and tier appear in every key that has a key at all. They change the answer, so they are in the key (L2).

3. Read and write

  • Read: validate the ID, check local memory and Redis, then apply the Bloom-filter check on a miss before the database lookup. Use single-flight to combine concurrent requests for the same price key (L8, L12).
  • Write: write-around — update the database, delete the cached copy (L9)
  • Invalidate after the database update: this avoids the write-ordering problem, though concurrent reads still need protection (L11)

4. Invalidation path

Admin changes price
    ↓
Database updated                                    immediate
    ↓
Local caches invalidate affected price keys          immediate
    ↓
Redis invalidates affected price keys                immediate
    ↓
CDN purges affected product responses                monitor completion
    ↓
Browser: no direct mechanism — bounded by its TTL    up to 10s

The browser reuse limit is 10 seconds. Staleness across the system also depends on successful invalidation delivery and CDN purges, so monitor both.

5. Attack defenses

AttackDefense
StampedeSingle-flight + refresh-ahead on top-100 products (L8)
PenetrationID validation, a current product-ID Bloom filter, and 60-second negative caching for confirmed misses; rate-limit requests (L12)
AvalancheTTL jitter on every price key (L10)
Hot key5-second local cache in front of Redis for top products (L5)
PoisoningCurrency and tier in the key; public is safe only because price is not per-user (L2, L12)

Bloom-filter policy: use the Lesson 12 filter to check product IDs, not prices or stock. A possible match still needs the normal lookup, and price cache keys must still include currency and tier. Before publishing a new product, add its ID to every filter used for rejection and clear any negative cache entry. If coverage is uncertain, bypass the filter.

6. Instrumentation

  • Hit ratio for the price: prefix specifically — not the global number (L13)
  • p99 latency on price lookups; alert if it exceeds 100ms
  • Track Bloom-filter rejections, bypasses, update failures and capacity; compare possible matches with database misses to spot wasted lookups
  • Alert on stale-price reports with Age attached, to identify the realm
  • Track invalidation-event and CDN-purge failures — either can leave a stale price in place

7. Failure mode

If Redis is unavailable, bypass its cache and any Bloom filter it hosts. Keep rate limits and database concurrency limits active so fallback traffic cannot grow without bound; return a temporary error when capacity is exhausted. Filter failures and database errors must never become “product not found” responses. For this example, assume the database can handle four times its usual load. Verify that assumption with a staging load test.

Do the other six

Product details. Product images. The asset bundle. Stock count. Reviews.

For this storefront, leave the cart badge response uncached to keep the policy simple. It belongs to one user and must never be reused as another user's answer. Cart data stored in Redis under a user-specific key is a separate decision.

The cart has needed a separate policy throughout the course. Requirement 1 is satisfied by explaining precisely why it is not shared.

The final assessment

When you can produce that document for a real system — and defend every TTL in it — you have finished this course.

The test is not whether you can list caching techniques. It is whether you can look at a piece of data and say: this belongs here, for this long, invalidated this way, and here is what happens when it goes wrong.

The end

The Surge arrives.

It arrives at the browser cache, and most of it stops there — never reaching the network at all.

What continues arrives at the edge, spread across the world, and is answered locally by copies already waiting.

What continues past that arrives at the application, and finds its answers in memory. The keys that are hot are already held close. The values approaching expiry have already been refreshed. The thousand requests that arrive for one missing key send one query and share its answer.

What reaches the database is a trickle. The database, which has been quietly caching all along, does not even notice.

And the Latency Lord, who built five weapons out of your own timers and your own keys, watches his attacks meet defenses you built throughout the course.

No single technique defeated him.

He was defeated by someone who had measured first.

Appendix — Technique Index

TechniqueLessonOne line
The SightL1Measure before caching anything
The NamingL2Every input that changes the answer goes in the key
WindkeepingL3Cache-Control, ETags, fingerprinted URLs
TidecallL4Edge caching, edge keys, purge vs version
StoneholdL5Local memory: fastest, unshared, dies on restart
StonesplitL6Shared cache, Redis, replication and failover
EmberdrawL7The database already caches — tune it before adding another
The Three DrawsL8Cache-aside, read-through, refresh-ahead, single-flight
The Three SealsL9Write-through, write-behind, write-around
The Letting GoL10Expiration is time; eviction is memory
The TruthbindingL11Invalidate after database updates. Version keys. Use TTL as the safety net.
(the trial)L12Five attacks, layered defenses, and Bloom filters
The MeasureL13Per-key metrics, p99, and deleting caches that don't earn their place
The ConvergenceL14All four realms, one design, every TTL defended