# Content-Addressed Caching in .NET: Cache Keys That Never Go Stale

> Cache invalidation is hard because we name cache entries after locations. Name them after a hash of their inputs instead, and the whole class of stale-read bugs stops being possible: change any input, get a different key, recompute under it. Git, Docker, and NuGet all run on this idea, and it works just as well inside your own application.

Published: 2026-07-25. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/content-addressed-cache-dotnet

**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 earned its place in the "two hard things" joke for a specific reason: 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 serves a wrong answer with complete confidence.

You depend on content addressing several times a day already.
[**Git**](https://git-scm.com/book/en/v2/Git-Internals-Git-Objects) addresses every blob, tree, and commit by a hash of its content.
[**Docker**](https://docs.docker.com/build/cache/) 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 has invalidation logic, because none of them needs any.

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:

```csharp
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:

```csharp
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.

There is no invalidation code in this design.
There is nothing to forget.

## Building a Key You Can Trust

The key builder is small, and two details in it are load-bearing:

```csharp
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 length prefix is the detail people skip.
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.
That is the exact failure this pattern exists to prevent, reintroduced in the first four lines.

The second detail is what you pass in.
Build the key next to the thing that defines the computation, and make the list exhaustive:

```csharp
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 the part that feels wrong and is not.
**The code is an input.**
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.

Note also that `TemplateSource` is the template's **content**, not its ID.
A key that references an input by name inherits exactly the staleness problem you are trying to leave behind.

## Reading and Writing

Everything above is about the key.
The store can be whatever you already use.
With [**HybridCache**](https://milanjovanovic.tech/blog/hybrid-cache-in-aspnetcore-new-caching-library) it is one call:

```csharp
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);
    }
}
```

The comment on `Expiration` is the whole payoff.
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, both render, both store.

In a location-keyed cache this is a [**cache stampede**](https://milanjovanovic.tech/blog/caching-in-aspnetcore-improving-application-performance), and the standard answer is to coalesce the concurrent factory calls so only one runs.
`HybridCache` does that for you, and so does [**FusionCache**](https://milanjovanovic.tech/blog/fusioncache-multi-level-caching-dotnet).

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.
The cost of losing the race is one duplicated computation.
The correctness of the entry is never in question.

That difference shows up when you write the store yourself:

```csharp
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.

One honest caveat on determinism.
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.
What is not fine is a computation that can produce a **wrong** answer for the same inputs, and that is the line where this pattern stops applying.

## You Traded Invalidation for Garbage Collection

Content addressing never deletes anything, so every edit strands the previous entry.
That is the cost, and it is real: the template team edits a header twenty times in an afternoon and 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:

```csharp
/// <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.
That keeps the spirit of the rest of the pattern: what is valid is **derived** from the live inputs, never tracked in a separate ledger that can drift.

## The One Failure Mode

Everything good about this pattern comes from one assumption, and there is exactly one way to break it: **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 anywhere to save you.
It is a quiet bug, and the usual shape 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 keep it closed:

- **Over-include.** A key that is too sensitive costs an unnecessary recompute. A key that is too coarse costs a wrong answer. That trade is not close.
- **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.ForX` method 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 is microseconds. Rendering a document, resizing an image, running a model, compiling something: easy call.

That covers more than it sounds like: 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 genuine freshness semantics.
A stock price, an inventory count, a user's notification badge: those change because the world changed, not because an input to a derivation changed.
Those still want a TTL and a [**deliberate caching strategy**](https://milanjovanovic.tech/blog/caching-in-aspnetcore-improving-application-performance), and no amount of hashing helps.

## One From Production

[**Katabench**](https://katabench.com), 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 into the key.
Editing either one 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.

The move worth making is smaller than adopting a pattern.
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, you can have a cache that is never wrong, and the only thing left to manage is disk.

If you want to see the idea running under real load, the sandboxed grading on [**Katabench**](https://katabench.com) 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, and that is the point. Old entries are never wrong, they are just 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 silently 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 genuine freshness semantics (a stock price, an inventory count) still needs a TTL, because the point of that cache is time, not derivation.

### 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. The pattern is the backbone of most build and artifact tooling.
