All articles

Modern .NET Backend Roadmap — Part 5: Caching

Cache the hot read path with IMemoryCache and GetOrCreateAsync, choose expirations, survive stampedes and invalidation, and know when Redis and HybridCache enter.

0 · log in to like, save & follow Share on LinkedIn Share on X

Every read endpoint we've built so far runs a query per request. Most of those queries return the same answer they returned two seconds ago. Caching is the highest-leverage performance work in a typical backend — and also the source of the two hardest problems in computer science, so this part covers both the code and the judgment: IMemoryCache patterns, expiration, stampede protection, when to move to Redis, and what never to cache.

Modern .NET Backend Roadmap — Part 5: Caching

In-memory caching with GetOrCreateAsync

The low-stock endpoint from part 3 is a perfect candidate: dashboard-style data, expensive-ish query, tolerates staleness.

[HttpGet("low-stock")]
public async Task<IActionResult> LowStock(CancellationToken ct)
{
    var result = await cache.GetOrCreateAsync("low-stock", async entry =>
    {
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30);
        return await queries.GetLowStockAsync(threshold: 5, ct);
    });
    return Ok(new { items = result });
}

GetOrCreateAsync collapses the check-fetch-store dance into one call: cache hit returns instantly; miss runs the factory and stores the result. Registration is one line — builder.Services.AddMemoryCache().

Verified against the running API — the first call hits SQLite via Dapper; the second returns the same payload from memory (the whole request round-trips in ~1 ms locally):

GET /products/low-stock   (1st) → queries the database
GET /products/low-stock   (2nd) → served from IMemoryCache, identical payload

Choosing expiration

  • Absolute expiration ("fresh at most 30s") caps staleness — right default for data the business reads.
  • Sliding expiration ("keep while being used") suits per-user session-ish data, but a hot key never expires — combine with an absolute cap.
  • Size limits: IMemoryCache does not bound memory unless you set SizeLimit and give entries sizes. Unbounded caches keyed by user input are a memory leak with extra steps.

Pick the shortest TTL the product tolerates, not the longest that helps the benchmark. "How stale can the low-stock tile be?" is a product question — ask it.

Invalidation: version keys over hunting entries

The moment writes exist, cached reads can lie. Two workable strategies:

  1. TTL-only — accept up to N seconds of staleness. Simplest, often correct.
  2. Explicit invalidationcache.Remove("low-stock") inside ProductService.ReserveAsync. Precise, but every new cached read must remember every write that affects it; this decays fast.

A scalable middle path is a version key: writes bump products:version, and read keys embed it (low-stock:v42). One bump invalidates every product-derived cache without enumerating them; stale entries age out by TTL.

Cache stampede

When a popular key expires, a hundred concurrent requests can all miss and all run the query — the spike caching was meant to prevent. GetOrCreateAsync does not guard this (concurrent factories can run). For genuinely hot keys, front the factory with a per-key lock or Lazy<Task<T>> pattern, or move the refresh out-of-band entirely: IHostedService refreshes the entry every 20 seconds, and requests only ever read.

From one server to several: Redis

IMemoryCache dies with the process and diverges across instances. When you scale out (part 6 puts this app in containers; part 7 runs it in Azure), promote shared state to a distributed cache:

builder.Services.AddStackExchangeRedisCache(o =>
    o.Configuration = builder.Configuration.GetConnectionString("Redis"));

IDistributedCache trades the convenience of object references for bytes over the network — you serialize (JsonSerializer is fine), you pay ~0.5–2 ms per hop, and every instance sees the same values. The pragmatic layering many teams land on:

  • L1: IMemoryCache, seconds-long TTL — absorbs the per-instance hot path.
  • L2: Redis, minutes-long TTL — shared truth across instances.
  • L3: the database.

(.NET 9+ formalizes exactly this as HybridCache, with stampede protection built in — if you're starting fresh on a current runtime, reach for it.)

A concrete Redis layer, serialized honestly

When you outgrow one process, the IDistributedCache version of the low-stock read looks like this — note that you now own serialization and the null-vs-missing distinction:

public class DistributedLowStockCache(IDistributedCache cache, IProductReadQueries queries)
{
    private static readonly DistributedCacheEntryOptions TtlOptions =
        new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2) };

    public async Task<IReadOnlyList<ProductSummary>> GetAsync(CancellationToken ct)
    {
        var cached = await cache.GetStringAsync("low-stock", ct);
        if (cached is not null)
            return JsonSerializer.Deserialize<List<ProductSummary>>(cached)!;

        var fresh = await queries.GetLowStockAsync(threshold: 5, ct);
        await cache.SetStringAsync("low-stock", JsonSerializer.Serialize(fresh), TtlOptions, ct);
        return fresh;
    }
}

Operational notes from running this shape in production:

  • Treat Redis as an accelerator, not a dependency. Wrap calls so a Redis outage degrades to database reads (log + fall through), instead of taking the site down with it.
  • Key discipline: prefix keys per app and per entity version (ga:products:v3:low-stock). The prefix makes SCAN-based debugging and bulk invalidation tractable.
  • Watch payload sizes. Serializing a 2 MB object graph per request moves the bottleneck from SQL to bandwidth. Cache the shaped read model, never the entity graph.

What to cache: a decision worksheet

Run each candidate endpoint through four questions:

  1. How often is it read vs. written? The low-stock tile: read constantly, changes on every sale — cache with short TTL. A user's own profile: read rarely by anyone but them — don't bother.
  2. What's the cost of a miss? A 3 ms primary-key lookup gains nothing from caching; a 400 ms aggregation gains everything.
  3. What's the cost of being stale? Marketing counts: zero. Available-stock during checkout: real money — don't cache, or verify at write time.
  4. Who sees the value? Shared-for-everyone data caches beautifully. Per-user data multiplies your memory by your user count — cache only the hottest, with sliding expiry and size limits.

Instrument the answer: emit hit/miss counters per cache key family (part 10's metrics make this two lines). A cache with a 30% hit rate is complexity without payoff — delete it.

HTTP-level caching

Don't forget the cache you don't operate: the client's. [ResponseCache(Duration = 60, Location = ResponseCacheLocation.Client)] emits Cache-Control: private,max-age=60 and the browser stops calling you entirely — the cheapest request is the one never made. Combine with ETag/If-None-Match for validation-based caching on larger payloads.

Output caching: whole responses, one attribute-free line

.NET's output caching middleware caches entire HTTP responses server-side — a different tool than [ResponseCache] (which only sets client headers) and than IMemoryCache (which caches data, not responses):

builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("catalog", p => p.Expire(TimeSpan.FromSeconds(30)).SetVaryByQuery("page"));
});
app.UseOutputCache();
[HttpGet]
[OutputCache(PolicyName = "catalog")]
public async Task<IActionResult> List(CancellationToken ct) => Ok(await products.ListAsync(ct));

The first request renders and serializes; the next 30 seconds of requests are served without executing the action at all — no controller, no service, no JSON serialization. SetVaryByQuery("page") keeps page 1 and page 2 as separate entries; tag-based eviction (p.Tag("products") + IOutputCacheStore.EvictByTagAsync("products", ct) after a write) gives you precise invalidation. Choose it when the response is the expensive artifact; choose data caching when multiple endpoints derive different responses from the same expensive data. They stack: output cache absorbs the stampede, data cache accelerates the misses.

What never to cache

  • Authorization decisions ("is this user an admin") — cache the data, re-evaluate the decision.
  • Anything keyed by unbounded user input without a size limit.
  • Personally identifiable data in a shared cache without the same access controls as the database.
  • Write results you'll read back for correctness (read-your-writes breaks under TTL).

Next

The app now has layers, auth, and speed — time to ship it somewhere. Part 6 packages the API as a container image: a multi-stage Dockerfile, non-root gotchas we actually hit (SQLite in a read-only directory), and the .dockerignore that saves your build.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.