Week 7: Cache Invalidation, Eviction & Content Delivery

Week 6 covered how data gets into a cache and stays in sync with the database. This week covers the two questions every cache eventually forces: when it's full, what gets thrown out (eviction), and when the underlying data changes, how the cache finds out (invalidation) — famously one of the two genuinely hard problems in computer science. It also zooms out to the CDN, which applies this exact same mental model — hits, misses, eviction, invalidation — at a different tier of the request path, geographically distributed at the network edge instead of co-located with the app servers. The thundering-herd failure mode named conceptually back in Week 3 gets a concrete fix here, and Week 8's message queues return as one of the tools invalidation events travel through.

Module 5 of 24 Week 7 of 28 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Compare LRU, LFU and TTL eviction policies and pick the right one for a given access pattern
  • Explain why cache invalidation is hard and design around stale-read and thundering-herd risks
  • Decide what belongs in a CDN and how CDN caching differs for static versus dynamic content

1. Cache Eviction Policies: LRU, LFU & TTL

A cache has bounded memory, so once it's full, adding a new entry means removing an existing one — an eviction policy decides which one. The three policies that come up constantly in interviews each make a different bet about what "likely to be needed again" means.

LRU vs. LFU vs. TTL
LRU (Least Recently Used):
  Evict the entry that hasn't been accessed for the longest time.
  Bet: recent access predicts near-future access (temporal locality).
  Cheap to reason about; the default policy in most caches
  (e.g. Redis's allkeys-lru).

LFU (Least Frequently Used):
  Evict the entry with the fewest total accesses.
  Bet: overall popularity predicts future access better than
  recency alone -- protects a consistently hot item from being
  evicted by a short burst of one-off requests.
  Costlier to track (needs a running access count per key) and
  can let old, no-longer-popular items linger ("aging" is needed
  to let their counts decay over time).

TTL (Time To Live):
  Every entry expires automatically after a fixed duration,
  regardless of access pattern.
  Not a competitor to LRU/LFU -- it's usually layered on top of
  one of them, bounding how stale any entry can ever get.

A classic LRU implementation combines a hash map (for O(1) key lookup) with a doubly linked list ordered by recency (for O(1) move-to-front on access and O(1) eviction from the tail) — worth knowing conceptually even outside a coding-specific interview, since "how would you implement that efficiently?" is a common follow-up.

LRU is the safe default; justify a deviation from it

Reach for LRU unless you can point to a specific access pattern it handles badly — for example, a large one-time scan (like a batch export) that floods the cache with items accessed exactly once, evicting genuinely hot data in the process. That specific scenario is the textbook argument for LFU or a scan-resistant variant instead.

2. Cache Invalidation: The Hard Problem

There's an old programmer's joke that there are only two hard problems in computer science: cache invalidation, naming things, and off-by-one errors. It's a joke because it's true — invalidation is hard specifically because it requires the cache to find out about a change it didn't cause, fast enough that nobody reads stale data, without coordinating so tightly that the cache stops helping performance at all.

Three strategies handle this, usually layered together rather than used alone:

invalidation strategies
TTL-based: entries expire automatically after N seconds.
  + Simple, needs no coordination with writers
  - Guarantees a staleness window up to N seconds long, even
    when nothing actually changed during that window

Write-driven (explicit): the write path deletes (or updates)
the corresponding cache key as part of the write, as in Week
6's cache-aside pattern.
  + Tight staleness window -- invalidation happens right when
    the underlying data changes
  - Only works if every writer goes through the same code path;
    a direct DB write that bypasses the app misses the cache
    entirely and leaves it stale indefinitely

Event-driven: a write publishes a change event (via a message
queue or a database change-data-capture stream, Week 8) that
one or more caches subscribe to and invalidate on.
  + Works across multiple caches/services without every writer
    needing direct knowledge of every cache
  - Adds infrastructure and a small propagation delay

The trickiest failure mode isn't the staleness window itself, it's a race between a write and a concurrent read repopulating the cache:

a stale-write race, and why "delete" beats "update"
t0: Reader A misses cache, starts reading OLD value from DB
t1: Writer updates DB to NEW value, deletes cache key
t2: Reader A finishes its DB read (still holds OLD value)
t3: Reader A writes OLD value into the cache
  -> cache now holds OLD value indefinitely, even though the
     DB already has NEW -- until the next write or TTL expiry

This race exists whether invalidation deletes or updates the
cache key; deleting doesn't eliminate it, but it does avoid a
second, worse race where two concurrent writers' cache-updates
land out of order and leave a wrong value cached with no
further write ever coming to correct it.

This is why real systems layer defenses rather than relying on any single mechanism: a short TTL as a backstop bounds how long any missed invalidation or lost race can persist, versioned cache keys (embedding a version number or timestamp in the key itself) let a reader detect it fetched a value older than the current write, and keeping the write-then-invalidate window as short as possible reduces how often the race above is even triggered.

Never claim a cache is "always consistent"

Any cache-plus-database system has some staleness window, even write-through (which is consistent at the moment of write but not necessarily against a concurrent read racing that write). The strong answer names the window explicitly and its bound (a TTL, a propagation delay) rather than asserting the cache is perfectly consistent — interviewers will usually probe exactly this claim if you make it.

3. Thundering Herd & Stampede Protection

Week 3 named thundering herd as a scalability failure mode; caching is where it shows up most concretely. When a popular ("hot") key expires or is invalidated, every concurrent request for that key misses the cache at once — and without protection, all of them independently query the database simultaneously, turning one expiring key into a sudden traffic spike the database wasn't sized for. This gets worse when many unrelated keys share the same TTL and expire in the same instant (a "cache stampede" across the whole cache, not just one key).

thundering herd, and the request-coalescing fix
Without protection:
  1,000 concurrent requests for hot_key, cache just expired
  -> all 1,000 miss -> all 1,000 hit the database at once

Request coalescing ("singleflight" / mutex-per-key):
  function get(key):
      value = cache.get(key)
      if value is not null: return value

      if not lock.tryAcquire(key):        # someone's already
          wait_briefly()                   # fetching this key
          return cache.get(key)            # then reuse their result

      value = db.query(key)
      cache.set(key, value, ttl=300)
      lock.release(key)
      return value
  -> only ONE request reaches the database; the other 999
     wait briefly and reuse its result

Other common mitigations, often combined: jittered TTLs — adding a small random offset to each entry's expiration so a batch of keys set at the same time doesn't all expire in the same instant; stale-while-revalidate — serving the slightly-stale cached value immediately while asynchronously refreshing it in the background, so no request ever has to wait on a slow database read; and probabilistic early expiration — occasionally refreshing a hot key slightly before its TTL actually expires, spreading refreshes out instead of letting them all cluster at the expiry boundary.

Thundering herd only matters for hot keys

Request coalescing and jittered TTLs are specifically worth mentioning when a key is genuinely popular enough that concurrent misses are likely — a rarely accessed key expiring is a non-event. Naming which keys in your design are hot enough to need this protection (and which aren't) shows calibrated judgment rather than defensive design applied everywhere by default.

4. CDNs: Caching at the Edge

A CDN (Content Delivery Network) is a globally distributed network of cache servers ("edge nodes" or "points of presence") positioned physically close to end users. It applies the exact same ideas as an application cache — hits, misses, eviction, TTLs, invalidation — but at a different point in the request path: instead of sitting between the app server and the database, it sits between the client and the origin server entirely, sometimes serving a request without the origin being involved at all.

CDN in the request path
[Client] --> [Nearest CDN Edge Node]
                    |
              (hit) returns immediately -- origin never touched
                    |
              (miss) --> [Origin: LB -> App Servers -> App Cache -> DB]
                          edge node caches the response, then
                          serves future nearby requests from itself

The value is almost entirely about Week 1's latency numbers: a round trip to a different continent can cost 100–150 ms, while a round trip to a nearby edge node costs a few milliseconds — for a user far from your origin data center, the CDN is often a bigger latency win than anything happening inside your architecture.

Static content (images, videos, JS/CSS bundles, anything identical for every user) is the easy case — it's essentially cache-aside at the edge, governed by Cache-Control headers the origin sets to tell the CDN how long a response can be reused. Dynamic content (a personalized feed, an API response scoped to one logged-in user) is much harder to cache at the edge, because caching it per-user at every edge location approaches caching almost nothing at all. Common approaches for dynamic content: cache only the pieces that are genuinely public and shared across users (a product price, not a personalized recommendation), use very short TTLs so staleness is bounded to seconds, or push logic to the edge itself (edge compute/serverless functions) so some personalization can happen without a full round trip to the origin.

The first CDN question is "is this the same for everyone?"

Before discussing TTLs or edge compute, ask whether the content is identical across all users. If yes, a CDN is close to a free win. If it's personalized, the CDN's benefit shrinks fast, and it's worth explicitly saying so rather than reflexively slapping "add a CDN" onto every design — the same discipline as justifying an application cache with a hit-ratio argument in Week 6.

5. Hands-on Exercise

Hands-on

Design caching, invalidation and CDN strategy for a news site

The site serves article pages (headline, body text, images) to a global audience. Editors occasionally issue corrections to already-published articles, and a small number of articles go viral and receive a huge, sudden traffic spike.

Requirements:

  1. Choose an eviction policy (LRU, LFU, or a combination with TTL) for the application-level article cache, and justify it against this site's access pattern.
  2. Design an invalidation strategy for when an editor corrects a published article — specify whether you'd use TTL-based, write-driven, or event-driven invalidation (or a combination), and why.
  3. Describe, step by step, what happens without protection when a viral article's cache entry expires under heavy concurrent traffic, and then describe your specific mitigation.
  4. Decide what should go through the CDN — article images, article body text, and the "breaking news" ticker (which updates every few minutes) — with a one-sentence justification for each based on whether it's static or dynamic.
Hint

The "breaking news" ticker in Step 4 is deliberately in between static and dynamic — it's the same for every user (so a CDN can cache it) but changes frequently (so its TTL matters a lot more than an article image's does). That's the kind of borderline case interviewers like to probe.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

When would LFU be a better eviction choice than LRU, and what's the specific cost of using LFU?

LFU is a better choice when a workload includes large one-time scans that would otherwise flood an LRU cache with recently-accessed-but-not-actually-popular items, evicting genuinely hot data in the process. The cost is that LFU needs to track an access count per key (more bookkeeping than LRU's simple recency ordering) and requires an aging mechanism so that once-popular items don't linger forever after their popularity fades.

Q2

Why is cache invalidation considered a genuinely hard problem rather than just "delete the key when the data changes"?

Even a write path that reliably deletes the cache key on every write can race with a concurrent read: a reader that started fetching the old value from the database before the write happened can still write that old value into the cache after the delete, leaving stale data cached with no further write to correct it. Invalidation also has to work across every writer (including ones that bypass the normal application code path) and across every cache that holds a copy, which is why real systems layer TTLs, write-driven deletes and event-driven propagation together rather than trusting any single mechanism.

Q3

What is thundering herd, and how does request coalescing prevent it?

Thundering herd happens when a hot cache key expires or is invalidated and many concurrent requests all miss at the same moment, each independently querying the database and turning one expiration into a traffic spike. Request coalescing fixes this by letting only the first request that misses actually query the database while it holds a per-key lock; every other concurrent request for that same key waits briefly and reuses that single result instead of issuing its own database query.

Q4

Why is dynamic, personalized content much harder to cache effectively at a CDN edge than static content?

Static content is identical for every user, so one cached copy at an edge node can serve every nearby request — a very high hit ratio for very little cached data. Personalized content is different per user, so caching it at the edge would mean caching a separate copy per user per location, which approaches caching almost nothing at all; the practical fixes are caching only the genuinely shared pieces, using very short TTLs, or moving some computation to edge functions instead of relying on a cached response.