Content-addressed caching names a cache entry after a hash of every input the value depends on, instead of a location. Change any input and the key changes, so the lookup misses and the value is recomputed under the new key. The whole class of stale-read bugs stops being possible, and there is no invalidation code left to get wrong.
Cache invalidation is in the "two hard things" joke because we name cache entries after locations.
invoice:1187:pdf points at a slot.
The slot keeps its name when the thing it describes changes, so correctness depends on someone remembering to evict, at every site that writes, forever.
Miss one and the cache keeps serving the old value.
You depend on content addressing several times a day already. Git addresses every blob, tree, and commit by a hash of its content. Docker rebuilds a layer only when the instruction or the files it copies change. NuGet, npm, and Cargo pin packages by content hash. None of those systems needs invalidation logic.
The same trick works inside an ASP.NET Core application, and it is underused there.
Location Keys and Content Keys
Say you render invoice PDFs from a template plus some data. The obvious key names the thing:
string key = $"invoice-pdf:{invoiceId}";
Now list everything that can change the bytes this key points at:
- the invoice data (line items, totals, the customer address)
- the template body, which the design team edits
- the culture used to format currency and dates
- the renderer itself, the day you upgrade it or fix a layout bug
Four inputs, and the key mentions one of them. The other three are handled by hope, or by eviction code written at each of the places that can change them. The template edit is the one that bites: nothing in the invoice changed, so nothing in the invoice's write path fires, and every cached PDF keeps rendering with last quarter's letterhead.
The content-addressed key names the inputs instead:
string key = ContentKey.From(templateId, templateSource, dataJson, culture, RendererVersion);
Edit the template and templateSource changes, so the key changes, so the lookup misses, so the PDF is rendered again and stored under the new key.
The old entry is not stale.
It is unreachable, because nothing computes its key anymore.
This design has no invalidation code, so there is no eviction call to forget.
Building a Key You Can Trust
The key builder is small, and two details in it need care:
using System.Security.Cryptography;
using System.Text;
public static class ContentKey
{
public static string From(params string?[] parts)
{
using IncrementalHash hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
foreach (string? part in parts)
{
byte[] bytes = Encoding.UTF8.GetBytes(part ?? string.Empty);
// Length-prefix every part so the boundary between parts is unambiguous.
hash.AppendData(BitConverter.GetBytes(bytes.Length));
hash.AppendData(bytes);
}
return Convert.ToHexStringLower(hash.GetHashAndReset());
}
}
The first detail is the length prefix.
Concatenate the parts with a separator instead, and ("a b", "c") and ("a", "b c") produce the same bytes, so two different inputs collide onto one entry.
One of them then reads the other's cached value.
The second detail is what you pass in. Build the key next to the thing that defines the computation, and make the list exhaustive:
public sealed record InvoiceRenderRequest(
string TemplateId,
string TemplateSource,
string DataJson,
string Culture);
public static class ContentKeys
{
// Bump on any change to how the renderer turns inputs into bytes.
private const string RendererVersion = "pdf-v3";
public static string ForInvoice(InvoiceRenderRequest request) => ContentKey.From(
request.TemplateId,
request.TemplateSource, // the template body, not just its name
request.DataJson,
request.Culture,
RendererVersion);
}
RendererVersion is there because the code is an input too.
Ship a renderer that fixes a rounding bug, leave the version alone, and every existing entry keeps serving output produced by the bug you just fixed.
Docker does the same thing when it puts the instruction text into the layer's cache key, not only the files the instruction copies.
TemplateSource is the template's content, not its ID.
A key that references an input by name has the same staleness problem as a location key.
Reading and Writing
Everything above is about the key. The store can be whatever you already use. With HybridCache it is one call:
public sealed class InvoiceRenderer(HybridCache cache, IPdfEngine engine)
{
public async Task<byte[]> RenderAsync(
InvoiceRenderRequest request,
CancellationToken cancellationToken = default)
{
string key = ContentKeys.ForInvoice(request);
return await cache.GetOrCreateAsync(
key,
(engine, request),
static (state, token) => state.engine.RenderAsync(state.request, token),
new HybridCacheEntryOptions
{
// The value can never be wrong, so expiration is a storage decision,
// not a correctness one. Pick it from your memory and Redis budget.
Expiration = TimeSpan.FromDays(30),
LocalCacheExpiration = TimeSpan.FromMinutes(10)
},
cancellationToken: cancellationToken);
}
}
In a location-keyed cache, TTL is a correctness knob: it bounds how long you serve wrong data, so it fights your hit rate directly. Here it bounds nothing but storage, so you set it from what you can afford to keep, and a longer TTL is strictly better.
Concurrency Stops Being a Correctness Problem
Two requests arrive for the same cold key at the same time. Both miss, and both render and store the value.
In a location-keyed cache this is a cache stampede, and the standard answer is to coalesce the concurrent factory calls so only one runs.
HybridCache does that for you, and so does FusionCache.
With a content key, the coalescing is a performance feature and nothing more. Both callers derived the key from the same inputs, and the computation is deterministic, so whatever the loser of the write race stored is the same value the winner stored. Losing the race costs one duplicated computation, and the entry is correct either way.
That difference shows up when you write the store yourself:
public void Store(string key, byte[] value)
{
string path = Path.Combine(_directory, key);
string tmp = $"{path}.{Guid.NewGuid():N}.tmp";
// Write to a unique temp file first, then move it into place, so a crash
// mid-write can never leave a truncated entry behind.
File.WriteAllBytes(tmp, value);
try
{
File.Move(tmp, path, overwrite: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// A concurrent writer got there first. It wrote the same value we did,
// so there is nothing to reconcile and nothing to retry.
try { File.Delete(tmp); } catch { /* best effort */ }
}
}
A directory of files named by key is a perfectly good cache when the values are megabytes rather than kilobytes, and it survives restarts and redeploys for free.
Put a ConcurrentDictionary in front of it and a hot entry costs a dictionary lookup instead of a disk read.
Many renderers embed a creation timestamp, so two runs produce different bytes from the same inputs. That is fine here, because both outputs are equally valid answers. The pattern stops applying when a computation can produce a wrong answer for the same inputs.
You Traded Invalidation for Garbage Collection
Content addressing never deletes anything, so every edit strands the previous entry. If the template team edits a header twenty times in an afternoon, you now hold twenty invoice renderings nobody will ever ask for again.
You have two ways to bound it, and they are not equivalent.
Expiry or size caps. A TTL, SizeLimit on IMemoryCache, or maxmemory with an LRU policy on Redis. Imprecise but free, and correct by construction, because evicting a live entry only costs a recompute.
A sweep, when you can enumerate the keys the current inputs can produce:
/// <summary>Deletes entries whose key no live input can produce.</summary>
public int Prune(IReadOnlySet<string> liveKeys)
{
int removed = 0;
foreach (string file in Directory.EnumerateFiles(_directory)
.Where(f => !liveKeys.Contains(Path.GetFileName(f))))
{
try
{
File.Delete(file);
removed++;
}
catch (IOException)
{
// Another instance is sweeping too. It is idempotent; let it win.
}
}
return removed;
}
Recompute the key for every live template and configuration at startup, hand the set to Prune, and the cache is bounded to exactly what the current content can produce.
The One Failure Mode
The pattern breaks when you leave an input out of the key.
Do that and the key stops changing when the value should. The cache serves the old answer indefinitely, and unlike a location-keyed cache, there is no eviction path to correct it. The usual cause is an input that did not look like one: an environment variable, a feature flag, a config file the renderer reads on its own, the machine's default culture.
Three habits prevent it:
- Over-include. A key that is too sensitive costs an unnecessary recompute, and a key that is too coarse costs a wrong answer.
- Make the input list one line of code. If the key is assembled in three places, one of them will fall behind. A single
ContentKeys.ForXmethod is the thing you review when the computation changes. - Treat implicit inputs as explicit. Anything the computation reads that is not a parameter (config, flags, ambient culture) either goes into the key or gets passed in as a parameter so it does.
When Should You Reach for a Content Key?
Reach for a content key when all three hold:
- The computation is deterministic, in the sense that any output it produces for a given set of inputs is equally valid.
- You can enumerate the inputs, all of them.
- Deriving the value is expensive relative to hashing it. Hashing a few KB takes microseconds, which is cheap next to rendering a document, resizing an image, running a model, or compiling something.
That covers report and document rendering, image transforms, code generation, compiled query plans, expensive pure aggregations over immutable data, and LLM calls at temperature zero.
It does not cover data with freshness semantics. A stock price, an inventory count, or a user's notification badge goes out of date without any input to a derivation changing. Those still need a TTL and a deliberate caching strategy.
One From Production
Katabench, my coding platform, runs on this for a value that has no hand-written answer at all.
Many katas do not ship an expected output. Instead the platform derives one: it runs the kata's reference solution against the test's input inside a sandbox and keeps whatever comes back. A full sandboxed execution per test is far too slow to repeat on every submission, so it has to be cached.
The location-keyed version of that cache would be {puzzleId}:{testName}, and it would need an eviction hook on every edit to a reference solution, a test input, or a seed script.
The content-keyed version puts the reference solution's source code and the test's full input (its arguments and its seed script) into the key.
Editing any of them produces a different key, a miss, and a fresh derivation, with no eviction code anywhere in the system.
The kind of bug that would keep me up otherwise (grading somebody's submission against an expected answer derived from a reference solution I edited last week) cannot happen, because there is no code path that produces it.
Summary
Cache invalidation is hard when the key is a location. It disappears when the key is a hash of everything the value depends on, because "the inputs changed" and "the key changed" become the same event.
Next time you cache an expensive derived value, write down every input it actually depends on, including the code that produces it. If that list is finite and you can hash it, the cache needs no invalidation code, and the only thing left to manage is storage space.
If you want to see the idea running under real load, the sandboxed grading on Katabench is built on it, and I am always happy to hear what breaks.
Frequently Asked Questions
What is content-addressed caching?
A caching scheme where the key is a cryptographic hash of every input the cached value depends on, rather than a name or location. If any input changes, the key changes, so the lookup misses and the value is recomputed under the new key. Stale reads become structurally impossible, and there is no invalidation code left to get wrong.
How do you invalidate a content-addressed cache?
You do not. Old entries become unreachable, because nothing computes their key anymore. What replaces invalidation is garbage collection: a TTL, a size cap, or a periodic sweep that deletes entries no live input can produce.
Does the code that produces the value belong in the cache key?
Yes. The code is an input like any other. If you change the renderer, the formatter, or the algorithm without changing the key, every existing entry keeps serving output from the old implementation. A version constant bumped on behavior changes, or a hash of the template or configuration the code reads, closes that hole.
When is content-addressed caching the wrong choice?
When the computation is not deterministic in a way that matters, when you cannot enumerate all of its inputs, or when hashing the inputs costs more than recomputing the value. Data with freshness semantics (a stock price, an inventory count) still needs a TTL, because it goes out of date without any of its inputs changing.
What systems use content-addressed storage?
Git addresses every blob, tree, and commit by the hash of its content. Docker keys each build layer on the instruction and the files it copies. NuGet, npm, and Cargo pin packages by content hash. Build systems like Bazel and Nx key compiled artifacts on hashed sources and flags.



