The Cache Key Is the Spec
Underneath every cache is a decision that gets far less scrutiny than it deserves: what counts as the same request. The cache key is where that decision lives, and it’s really a specification in disguise — a claim that any two lookups sharing this key deserve the same answer. When the key captures every input that actually affects the result, the cache is sound. When it leaves something out, the cache will confidently return one answer to two situations that should have gotten different ones, and it will do this smoothly, quickly, and without complaint, which is exactly what makes it dangerous.
The failure mode has a specific shape worth naming: a cache key built from the inputs that were obviously relevant, missing an input that mattered but wasn’t obviously part of “the request.” A price calculation keyed on product ID alone, when the actual price also depends on the requesting user’s region — every user in every region gets whoever’s answer happened to populate the cache first. A permission check keyed on resource ID, when the answer also depends on the caller’s role — the first caller to check determines what every subsequent caller of any role sees. Nothing about the code looks wrong; the cache faithfully returns exactly what was stored under that key. The bug is entirely in the decision about which dimensions belonged in the key, made once, upstream of everything downstream that trusts it.
This connects directly to the earlier point about invisibility: a cache key that’s missing a dimension produces answers that are indistinguishable in shape from correct ones. There’s no malformed output, no exception, no log line — just the wrong user occasionally getting the wrong price, the wrong caller occasionally getting the wrong permission, each instance looking like an isolated one-off rather than a systemic key design flaw. These bugs tend to surface as scattered, hard-to-reproduce reports — “sometimes I see stale data, can’t tell when” — precisely because reproducing them requires hitting the same cache entry from two different real-world contexts that the key failed to distinguish, which is exactly the kind of thing that’s easy to trigger by accident and hard to trigger on purpose.
The corrective question is almost mechanical once you know to ask it: for every output this code can produce, list everything the output actually depends on, then check that every one of those things is in the key. Not what seemed like the natural identifier — user ID, resource ID, whatever felt like “the thing being looked up” — but the complete set of inputs that participate in computing the result, including the ones that feel like context rather than input: locale, permissions, feature flags, time-of-day pricing, A/B test assignment. Anything that can make the same nominal request produce a different correct answer belongs in the key, and leaving it out doesn’t make the cache faster in some acceptable tradeoff sense — it makes the cache wrong for a subset of cases that happen to be invisible until someone notices.
This is also why cache keys deserve to be treated as a piece of the public contract of the code that uses them, reviewed with the same seriousness as a function signature, because in a real sense that’s what they are — a signature for “what varies.” A key that’s too broad just wastes cache space, a forgiving mistake. A key that’s too narrow silently merges cases that were never the same, a much less forgiving one, and one that inherits every difficulty the rest of this thread has described: invisible until triggered, hard to reproduce, and indistinguishable from correct output until someone downstream notices the answer doesn’t match reality.