open atlas
↑ Back to track
System Design Foundations SD · 05 · 04

Cache invalidation

Keeping the cache honest is the genuinely hard part. TTL, explicit invalidation, and versioned keys each trade staleness against complexity, write-time races produce stale reads, and the deepest trap is treating the cache as the source of truth instead of a disposable copy of it.

SD Middle ◷ 20 min
Level
FoundationsJuniorMiddleSenior

A team shipped explicit invalidation: every time the database changed, they deleted the matching cache key so the next read would reload fresh. Cleaner than a TTL, they thought. For weeks it worked. Then a user reported their old profile photo kept reappearing minutes after they changed it. The cause was a race: a read missed the key and started loading the old value from the database at the same instant a write updated the database and deleted the key. The slow read finished last and wrote the stale value it had fetched back into the now-empty cache — re-poisoning it for the full TTL. Nothing was broken; the ordering was just unlucky. This is why “there are only two hard things in computer science: cache invalidation and naming things” is a joke engineers tell with a wince.

Why invalidation is the hard problem

Every cache holds a copy of data that lives somewhere else. The moment the original changes, the copy is wrong — and the cache has no idea, because (as the strategies lesson showed) lazy caches don’t watch the database. Cache invalidation is the discipline of getting the wrong copy out of the cache, on time, without breaking correctness or hammering the backend. It is famously hard not because any single technique is complex, but because it sits exactly where two independent timelines — your writes and your reads — interleave unpredictably across a distributed system. There are three families of approach, and each trades the same two things: staleness (how wrong can a read be?) against complexity/coupling (how much must the write path know about the cache?).

The three approaches

Before choosing, ask yourself: how wrong can a read be, and who pays the cost if invalidation misses a site? The answer tells you which trade you’re willing to make.

  • TTL expiry — let every entry expire after N seconds; the write path does nothing. This is the simplest and most decoupled: the cache self-heals, writers never touch it. The cost is bounded staleness — a value can be wrong for up to the TTL. It’s the right default for data that tolerates seconds-to-minutes of lag (feeds, listings, rendered fragments).
  • Explicit invalidation — on every write, actively delete (or update) the affected cache key so the next read reloads. Staleness drops toward zero, but now the write path is coupled to the cache: it must know every key derived from the data it changed, and a single missed invalidation site leaves a key stale forever (until its TTL, if any). This coupling is where most cache bugs live.
  • Versioned keys — bake a version into the key itself: user:42:v7. A write doesn’t delete anything; it bumps the version (v7 → v8), so reads of the new version simply miss a key that was never written and load fresh, while the old v7 entries are now orphaned and eventually evicted. This sidesteps the delete-race entirely (you never mutate an existing key) at the cost of leaving garbage to be evicted and needing somewhere to track the current version.

The senior framing mirrors the strategies lesson: there is no free option. TTL is simple but stale; explicit is fresh but coupled and race-prone; versioning is race-free but leaves orphans and needs a version source. You pick per data class, and you usually combine — explicit invalidation with a TTL backstop, so a missed invalidation self-corrects instead of poisoning forever. Together these three approaches mean that choosing invalidation is really choosing where the bug lives when something goes wrong: in the staleness window, in the coupling surface, or in orphaned old versions.

Why this works

Why does explicit invalidation race, and why does versioning escape the race? The race is a classic read-write interleave: a reader misses key K and starts a slow load of the old DB value; meanwhile a writer updates the DB and deletes K. If the slow read finishes after the delete, it writes the old value back into the empty cache — a stale entry created by correct code in an unlucky order (the hook). Deleting can’t fix this because the reader is already holding the stale value in flight. Versioned keys escape it because the writer bumps to v8 and the reader’s stale value is being written to the old key v7 — which no new read will ever request. The new version is a fresh, never-before-written key, so its first read is a clean miss against the current DB state. You traded “mutate a shared key and hope the ordering is kind” for “write to a brand-new key per version,” which has no ordering to get wrong.

Stale reads, negative caching, and the dogpile

Three more hazards sit around invalidation:

  • Stale reads are the accepted cost of TTL/lazy strategies, but they become a correctness bug when the data class can’t tolerate them (the balance-shown-an-hour-late from the strategies lesson). The fix isn’t “never be stale” — it’s matching the staleness budget to the data: bound it with a short TTL, or pay the coupling cost of explicit invalidation, where it actually matters.
  • Negative caching — cache the absence of a value (a “not found”), so repeated lookups of a missing key don’t hammer the database each time. Vital defense against a flood of misses for non-existent keys (e.g. an attacker requesting random IDs), but it has its own invalidation problem: when the thing finally does get created, a too-long negative-cache TTL hides it. Negative entries need short TTLs or explicit invalidation on create.
  • The dogpile — the stampede from the distributed-cache lesson is itself an invalidation event: invalidating (deleting) a hot key creates an instant gap that N concurrent readers all rush to refill. So invalidation and stampede protection are coupled: an aggressive “delete on every write” on a hot key is a stampede trigger, which is why hot keys often prefer update-in-place or versioning over delete. When you see a hot key protected against stampede with single-flight, make sure the invalidation strategy doesn’t undo that protection by deleting the key on every write anyway.

The deepest anti-pattern: cache as source of truth

The most dangerous mistake is not a race; it’s a category error. A cache is a disposable copy of authoritative data that lives in the durable store. Treat it as the source of truth — write only to the cache and lazily flush, or assume “if it’s in the cache it must be correct” — and you’ve built a system that loses data when the cache restarts (it’s not durable), serves whatever happened to be cached even when the DB disagrees, and can’t be rebuilt from scratch because nothing knows the real state. Every invalidation strategy above assumes the database is authoritative and the cache is expendable: you must be able to flush the entire cache at any moment and have the system recover (slowly, via misses) to correct answers. If flushing the cache would lose data or serve wrong results indefinitely, it isn’t a cache — it’s an undurable primary database, and you’ve inherited all of its consistency problems with none of its guarantees.

Common mistake

A concrete version of the source-of-truth trap: using write-back caching (from the strategies lesson) and then reading from the cache as if it’s authoritative, while the asynchronous flush to the DB lags or fails silently. The cache shows the “right” number, the database shows an older one, and any process that reads the DB directly (a report, a second service, a backup) sees the wrong value — and if the cache evicts or crashes before the flush, the acknowledged write is simply gone. The rule that prevents this whole class of bug: writes are acknowledged only once they’re in the durable store, and the cache is treated as a performance layer you can drop at any time. The cache makes reads fast; it does not make data exist.

Quiz

With explicit 'delete the key on every write' invalidation, a user's stale value occasionally reappears right after they update it. What is happening, and which approach removes the race?

Quiz

An architect proposes writing all updates to Redis only and flushing to Postgres asynchronously, then reading from Redis as the authoritative value. What's the core objection?

Complete the analogy

To invalidate without the delete-race, bake a version into the key (user:42:v7) and bump it on every write; this works because the reader's stale write lands on the now-orphaned old _______, which no new read will ever request — there is no shared mutable key for the two timelines to fight over.

Recall before you leave
  1. 01
    Why is cache invalidation hard, and what do the three approaches trade?
  2. 02
    Explain the write-time race and why versioned keys avoid it.
  3. 03
    What are negative caching, the dogpile-as-invalidation link, and the source-of-truth anti-pattern?
Recap

Cache invalidation is the genuinely hard part of caching — not because a single technique is complex, but because it lives where your read and write timelines interleave across a distributed system, and a cache silently holds a copy that goes wrong the instant the original changes. The three approaches all trade staleness against complexity/coupling: TTL (the write path does nothing, self-healing but bounded-stale), explicit invalidation (delete on write, near-fresh but the write path is coupled to every derived key and a missed site stays stale forever), and versioned keys (bump a version per write, race-free but leaving orphans). The write-time race — a slow read writing the old value back after an invalidating delete — is why versioning, which never mutates a shared key, beats delete; the practical default is explicit invalidation with a TTL backstop. Around this sit stale reads (match the budget to the data class), negative caching (cache absence, but with short TTLs so creates aren’t hidden), and the dogpile (invalidating a hot key is a stampede trigger). The deepest trap is the category error of treating the cache as the source of truth: a cache is a disposable copy of an authoritative durable store, and you must be able to flush it entirely at any moment and recover correct answers — if you can’t, you’ve built an undurable database wearing a cache’s clothes. Now when you review a system design and see writes acknowledged before they reach the durable store, ask one question: if the cache restarts right now, does any data disappear? If yes, you’re looking at an undurable database, not a cache.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.