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

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

10 min read··

architecturecachingdotnetperformance

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

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.

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:

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:

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

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, 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. 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:

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:

/// <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, and no amount of hashing helps.

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

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.