The Last CachebenderBook Five · War
Book Five · War
Book Five · War · Lesson 12 of 14
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.