Book Five · War
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.
The Latency Lord's Attacks
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:
- The attack — watch it break the storefront.
- Your attempt — before reading on, use what you already know to propose a defense.
- 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.
He does not need to take the cache away. He only needs to choose the moment it empties.
| Technique | From | What it does |
|---|---|---|
| Single-flight | L8 | One request loads; the other 999 wait for its answer. 1,000 queries become 1. |
| Refresh-ahead | L8 | Refresh hot keys before expiry — the gap never opens. |
| TTL jitter | L10 | Different keys expire at different moments, so stampedes never coincide. |
| Serve stale while refreshing | L3 | Hand out the old value instantly, refresh behind it. Nobody waits, nobody misses. |
| Origin shield | L4 | The 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.
He begins writing down names of things that were never real.
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
nullusually 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.
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 setIf 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.
He simply finds every expiry timer in the kingdom and sets them all to the same hour.
| Technique | From | What it does |
|---|---|---|
| TTL jitter | L10 | ttl = 3600 + random(0, 600) spreads 50,000 expiries across 10 minutes |
| Tiered TTLs | L10 | Different data types get different lifetimes, so they never align |
| Cache warming | L2 | After a restart, reload deliberately rather than under live traffic |
| Single-flight | L8 | Limits the damage — one query per key, not one per request |
| Serve stale on failure | L3, L11 | If 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.
He does not need to attack every node in the cluster. He needs the one node that holds that key.
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
Varynot set on a response that varies by language, currency, or authenticationprivatenot 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
He does not need to break into the Edge Kingdom. He only needs to be mistaken for someone else.
| Technique | From | What it does |
|---|---|---|
| Key discipline | L2 | Every input that changes the answer is in the key. Including the user. |
private on anything personal | L3 | Shared caches are forbidden from storing it |
no-store on anything sensitive | L3 | Never written to disk anywhere |
Vary set correctly | L4 | Language, currency, encoding declared — and never Vary: Cookie |
| Key normalisation | L4 | Strip headers and parameters that must not affect the response |
| Never cache authenticated responses in a shared cache | L3, L4 | The absolute rule |
| Do not cache errors — or cache them for seconds | L10 | A cached 500 turns a blip into an outage |
The blanket rule worth memorising:
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:
| Attack | Root cause |
|---|---|
| Stampede | Your TTL and the gap after it |
| Penetration | Your keys — you only cached successes |
| Avalanche | Your TTLs, all set to the same value |
| Hot key | Your key design and partitioning |
| Poisoning | Your 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.
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?
“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.
Restoring Balance
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.
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 ratio | Eviction rate | Diagnosis |
|---|---|---|
| Low | High | Cache is too small. Items evicted before reuse. |
| Low | Low | TTLs too short, or the data genuinely isn't reused. |
| High | High | Working, but under pressure. More memory would help. |
| High | Low | Healthy. |
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.
| Header | Meaning |
|---|---|
Age: 240 | This copy has been in a cache for 240 seconds |
X-Cache: HIT / MISS | Common CDN convention (non-standard) |
Cache-Status: cdn; hit, origin; miss | The 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:
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:
- Which realm served it?
- How old was the copy?
- If it missed, where did the time go?
Then turn off Redis under load and watch what happens.
“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.
The Final Battle
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
| # | Requirement | From |
|---|---|---|
| 1 | Every one of the seven parts assigned to a realm — or marked never cached, with a reason | L1–L7 |
| 2 | A cache key and a TTL for each cached part | L2, L10 |
| 3 | A read strategy and a write strategy for the application layer | L8, L9 |
| 4 | An invalidation path for every mutable part, across every realm that holds it | L11 |
| 5 | A named defense for each of the five attacks | L12 |
| 6 | An instrumentation plan — what you measure, what alerts you | L13 |
| 7 | A stated failure mode: what happens when the cache dies | L6, 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
| Realm | Key | TTL | Why |
|---|---|---|---|
| Browser | URL + currency + customer tier | max-age=10, must-revalidate | A browser cannot be purged, so 10 seconds caps reuse without revalidation |
| CDN | URL + currency + customer tier | 300s, purged on price change | A purge updates shared copies promptly; alert if it fails |
| Redis | price:{id}:{currency}:{tier} | 300s + jitter | Jitter prevents avalanche (L10) |
| Local cache | price:{id}:{currency}:{tier} | 5s | Absorbs 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
| Attack | Defense |
|---|---|
| Stampede | Single-flight + refresh-ahead on top-100 products (L8) |
| Penetration | ID validation, a current product-ID Bloom filter, and 60-second negative caching for confirmed misses; rate-limit requests (L12) |
| Avalanche | TTL jitter on every price key (L10) |
| Hot key | 5-second local cache in front of Redis for top products (L5) |
| Poisoning | Currency 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
Ageattached, 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 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
| Technique | Lesson | One line |
|---|---|---|
| The Sight | L1 | Measure before caching anything |
| The Naming | L2 | Every input that changes the answer goes in the key |
| Windkeeping | L3 | Cache-Control, ETags, fingerprinted URLs |
| Tidecall | L4 | Edge caching, edge keys, purge vs version |
| Stonehold | L5 | Local memory: fastest, unshared, dies on restart |
| Stonesplit | L6 | Shared cache, Redis, replication and failover |
| Emberdraw | L7 | The database already caches — tune it before adding another |
| The Three Draws | L8 | Cache-aside, read-through, refresh-ahead, single-flight |
| The Three Seals | L9 | Write-through, write-behind, write-around |
| The Letting Go | L10 | Expiration is time; eviction is memory |
| The Truthbinding | L11 | Invalidate after database updates. Version keys. Use TTL as the safety net. |
| (the trial) | L12 | Five attacks, layered defenses, and Bloom filters |
| The Measure | L13 | Per-key metrics, p99, and deleting caches that don't earn their place |
| The Convergence | L14 | All four realms, one design, every TTL defended |