The Cache Key That Forgot the Photo Was the Same Photo

The Cache Key That Forgot the Photo Was the Same Photo

Every app with user photos eventually adopts the same two defaults, each picked independently, each obviously correct on its own: sign every media URL fresh per request, so a link can't be shared, guessed, or replayed after it should have expired — and cache fetched images to disk, so a photo the user has already looked at once doesn't cost a second download the next time it's on screen.

Neither of those is a mistake. Nobody sits down and designs a caching bug on purpose. What actually happens is that both decisions get made by different people, at different times, for different and entirely defensible reasons, and nobody in the room is thinking about how they interact — because on the day either one shipped, it worked. The failure isn't in either decision. It's in the seam between them, and seams like that don't show up in a code review of either change alone.

Part one: the collision nobody designed on purpose

An image-caching library needs a cache key — some string that answers "have I already got this?" The obvious, default answer is the URL itself, and for an unsigned URL that's exactly right: the same address really does mean the same file, forever. The moment a URL is signed, that assumption quietly stops being true. A signature is a query parameter that changes on every single mint, on the exact same underlying file, for the entire reason the signature exists in the first place — to expire. Key the cache on the full URL, and a photo fetched ten seconds ago on one screen looks, to the cache, like a photo it has never seen before on the very next screen it's asked to draw. Every re-render of the same picture becomes a full network round trip, invisibly, because none of this throws, logs, or even looks unusual in isolation. It's just a cache with a 0% hit rate, wearing a cache's clothing, doing exactly what it was told.

The actual fix is almost embarrassingly small once you see the shape of it:

function stableCacheKey(url):
  return url.split("?")[0]   // drop the query string the signature lives in

Strip the part of the URL that's guaranteed to change on every mint, keep the part that identifies the actual file, and two signed reads of the same photo collapse into the same cache entry. The entire fix is one function. The hard part was never the code — it was noticing that "the same URL" and "the same image" had silently become two different questions, and that the caching layer was still answering the first one.

Part two: the fix that existed and didn't spread

Here's the part that actually cost the time, and the part that makes this worth writing about rather than filing under "one-line bug": the fix already existed in the codebase, in a different component, months before anyone noticed the second one was missing it. One general-purpose image-rendering component had the stable cache key from the start. A second, more specialized component — built later, for a narrower case (a small circular avatar rather than an arbitrary photo box) — didn't. It built its own request straight from the raw URL, bypassing the shared helper entirely, because at the moment it was written that felt like the simpler path: wrap the string in the library's provider class, done, ship it. Nobody was wrong to reach for the shortcut. Nobody who wrote either component knew the other one existed as a pattern to match, because there was no single place that said "this is how a signed URL becomes a cache key" loudly enough for the second author to trip over it.

Both components ended up rendering the same underlying photo, on different screens, in the same app. One reused the cache correctly and paid for the fetch exactly once. The other re-signed, re-fetched, and — on any transient hiccup in that entirely redundant network call — silently fell back to a placeholder, because "on load failure, show a neutral placeholder instead of a broken-image icon" is also the correct, defensive default, in exactly the same way the ad-pixel's graceful no-op was correct on its own. Two good decisions, made without a way to see each other, produced one bad outcome that looked, from the outside, like nothing had gone wrong at all.

How it actually surfaced

It didn't show up as an error, a crash log, or a failed test — because nothing failed in a way any of those catch. It showed up as a report that sounded, at first, like a non-bug: "the photo shows up in the list, but not when you open the profile." That sentence is easy to misread as a data problem — maybe the detail screen is reading the wrong field, maybe the photo was never actually uploaded — because a UI showing initials instead of a photo is supposed to mean "this person has no photo." It takes a second, more annoying question to get past that read: does the list screen and the detail screen render the same field, through the same code? Once the answer to that is no, the bug stops being about missing data and starts being about two components disagreeing on how to fetch it — which is a much smaller, much more findable problem than the one the symptom initially suggested.

What we'd do differently

One helper owns "how do we cache a network image," and nothing else is allowed to build its own. The moment a second call site constructs its own request straight from a raw URL — even for a case that looks simpler — you've created a second place that can drift from the first, silently, with no error to catch it drifting. The fix isn't a rule that requires great discipline to follow; it's making the shortcut harder to reach for than the shared helper, so the easy path and the correct path are the same path.

A defensive fallback needs a paper trail. Showing a placeholder instead of a broken-image icon is the right call, full stop. Showing it with no log line saying why means "no photo exists" and "photo failed to load" render identically on screen, and only one of those is actually fine — so the one time it isn't fine, nothing in the system is positioned to tell you.

When the same real-world thing can be reached two different ways, that's the bug waiting to happen — not the two ways themselves. The fix here was never "cache harder" or "retry more." It was noticing that two components answering the exact same question — what image is this? — had quietly stopped agreeing on how to answer it, and that disagreement is the only thing that actually needed fixing.

None of the individual decisions above were careless. Signing URLs for security is correct. Caching images to disk is correct. Falling back to a placeholder on a failed load is correct. Reaching for the simplest implementation in a new, narrower component is a completely normal thing to do on a Tuesday. The gap between "the photo we have" and "the photo the screen shows" was never one bad line — it was several good ones, shipped months apart, that never got introduced to each other.