nothing matters — notes on small systems

Cache invalidation is mostly a naming problem

· 4 min read

The joke is that cache invalidation is one of the two hard problems. In practice, most of the stale-data incidents I have been paged for were not invalidation failures at all. They were cases where two semantically different values had been given the same key, and the cache did exactly what it was told.

What the key actually promises

A cache key is a promise that everything stored under it is interchangeable. If the application can produce two responses that differ for reasons not captured in the key — user, locale, feature flag, upstream version — the promise is false. No amount of purge logic fixes a key that lies.

A useful test: can I name every input that changes the value? If the answer is “mostly,” the key is not finished. Inputs that are expensive to hash usually belong at a wider scope, with a shorter TTL, instead of being quietly omitted.

Three habits that removed most of the problem

Put the version in the key. A deploy that changes the shape of a cached value should change the namespace, not the entries. Renaming summary:v3 to summary:v4 is a cheap invalidation: the old entries expire on their own and nothing has to be contacted.

Separate identity from presentation. The record for a user and the rendered card for that user are different values with different lifetimes. Caching the record for an hour and the card for a minute is easy. Caching one thing that is sometimes the other is how a profile edit shows up on one page and not another.

Prefer expiry over purge. A purge path has to be correct at the worst possible moment — during a partial outage, with a queue backing up. An expiry path is correct by default and merely late in the worst case. For anything whose staleness is measured in minutes rather than milliseconds, expiry is the better failure mode.

When purge is still the right answer

Some values genuinely must change immediately: a revoked session, a taken-down page, a price correction. For those, the key should be narrow enough that a purge touches exactly one logical object, and the purge should be possible without knowing every cache node. If a purge requires a broadcast to everyone, the key is too broad.

If the cache can hold a value the application would never return fresh, the bug is in what we called the value, not in the fact that we stored it.

Debugging checklist

1. Reproduce without the cache. If it is still wrong, it is not the cache.
2. Dump the key and the value next to it. Is the value plausible for that key?
3. Compare the key against every input the value depends on.
4. Check the scope: is a user-independent value stored under a shared key, or
   the reverse?
5. Only then look at purge paths and TTLs.

Steps one through three resolve most of these in a few minutes. The rest of the checklist exists so that we do not start with a purge queue and work backwards.