Phil Karlton's line about cache invalidation gets quoted so often that it has stopped meaning anything. The joke lands, everyone nods, and then people go and add a cache without deciding a single one of the things that actually makes invalidation hard.
Deleting a key is not hard. What is hard is that the moment you add a cache, you no longer have one source of truth — you have several, and they are allowed to disagree. Most designs never say for how long, or what should happen when they do.
A cache is a second source of truth
It helps to stop thinking of a cache as an optimisation and start thinking of it as a replica you did not plan for. Replicas raise questions you already know how to ask: who writes, how do writes propagate, and what does a reader see in the window before they do.
So the useful question is never "should we cache this?" — the answer is usually yes. It is:
How wrong can this be? A stale article title is invisible. A stale account balance is a support ticket. A stale permission check is a breach.
For how long? This is your staleness budget, and it should be a number you chose, not one that fell out of a default.
Who notices first? If the answer is "the user, and they cannot do anything about it," you need an active invalidation path, not just an expiry.
TTL is a guess; invalidation is a promise
Every strategy is a different answer to "how does a change get out?" — and each one fails differently, which matters more than how each one performs.
Strategy | Staleness window | Fails by |
|---|---|---|
TTL only | Up to the full TTL | Being quietly wrong for a bounded time. Predictable, and often fine. |
Write-through | Near zero | Coupling writes to the cache. A cache outage becomes a write outage. |
Purge on write | Near zero, when it works | Silence. A dropped purge looks exactly like a cache hit. |
Versioned keys | Zero | Unbounded growth — old versions never get read, and never get evicted either. |
The row worth staring at is the third one. Purge-on-write is the strategy people reach for when TTL feels too slow, and it is the only one whose failure is invisible. A TTL that is too long is a complaint. A purge that silently failed is a bug you find three weeks later, from a screenshot.
Which leads to the rule I would keep if I could only keep one: active invalidation is an optimisation on top of expiry, never a replacement for it. Keep the TTL. Let the purge make things fast, and let the TTL make them correct.
A worked example
Here is a small system with two services: one that owns the content and one that displays it. The reader never touches the database. Both sides cache, for different reasons and on different clocks.
The layers are not redundant. The render cache exists because turning stored documents into HTML is CPU work that produces the same bytes every time. The memory cache exists because a page refresh should not become a network round trip. They have different TTLs because they are protecting different resources.
The dashed line is the whole design. On publish, the writer tells the reader to drop what it has. When that call succeeds, the change is live in milliseconds. When it fails — the reader is deploying, the network blips, someone rotated a secret — nothing breaks. The TTL still expires, and the change lands within ten minutes instead of instantly.
Degrade toward stale, not toward empty
The most common cache bug I see is not staleness. It is a cache that, on failure, confidently returns nothing.
const hit = readCache(key);
if (hit?.fresh) return hit.value;
const data = await fetchUpstream(key);
// The important line. When upstream is down, an expired copy is
// still better than an empty page — the reader cannot tell the
// difference between "nothing published" and "backend unreachable",
// but only one of those is true.
if (!data) return hit?.value ?? EMPTY;
writeCache(key, data);
return data;Without that fallback, an upstream blip turns a cache into an outage amplifier: every expiring key becomes a failed request, and the page an old copy would have rendered perfectly now renders blank.
The failure modes that actually bite
Caching a legitimate empty result
This one caught me while testing the exact system above. Before any content existed, a page load cached an entirely valid response: an empty list, HTTP 200, TTL ten minutes. Then content was published — and the page kept rendering empty, because there was nothing wrong to detect. The cache was doing precisely what it was told.
An empty result is a real result and gets cached like any other. If publishing does not purge, your first article shows up ten minutes after you publish it, and you spend those ten minutes reading your own cache code.
Per-instance caches
An in-process map on a serverless platform is not a cache — it is one cache per warm instance. Two refreshes can hit two instances and disagree, and a purge that reaches one of them leaves the others stale. That is an acceptable trade for content that is merely late, and completely unacceptable for anything a user just changed and expects to see.
Know which one you are building. If a user must see their own write immediately, either give them a read-your-writes path that skips the cache, or move the cache somewhere shared.
Stampede on expiry
A popular key expires and every in-flight request misses at once, so the origin takes the full load in a burst — usually at the worst moment, because popular keys expire under load. The fixes are cheap: serve stale while one request refreshes in the background, jitter the TTLs so keys do not expire in lockstep, or let a single request hold a lock and refresh on everyone's behalf.
What I would actually reach for
For most systems, in this order:
Start with a TTL and nothing else. Pick the number deliberately. Ship it. A surprising amount of content tolerates being a few minutes old, and you have added one moving part instead of three.
Add purge-on-write when the wait becomes the complaint. Keep the TTL underneath it as the backstop, and treat the purge as best-effort — a failed purge must never fail the write that triggered it.
Reach for a shared cache when correctness needs it, not when traffic does. Per-instance memory scales fine; what it cannot do is guarantee two readers agree.
Cache-Controlis free and you are probably ignoring it. A CDN honouringstale-while-revalidateabsorbs more load than most application caches you will write.
None of this is exotic. It is mostly the discipline of writing down the staleness budget before writing the cache, and of remembering that the cache will be wrong — the design question is only whether it is wrong in a way you chose.
Karlton was right. Just not for the reason the joke implies. Invalidation is hard because it is a distributed systems problem wearing a one-line API.