Building Blocks Intermediate 5 min read

Redis Caching in ASP.NET Core: When, Where, How

How to use Redis with ASP.NET Core: IMemoryCache vs IDistributedCache, cache-aside vs write-through, invalidation strategies, and the wiring code that scales.

Table of contents
  1. When does adding a cache actually help?
  2. What numbers should I budget for cache decisions?
  3. What does the minimal cache-aside architecture look like?
  4. What is the .NET 10 wiring for IDistributedCache + Redis?
  5. How do I invalidate without breaking everything?
  6. What failure modes does caching introduce?
  7. When should you not add a cache?
  8. Where should you go from here?

The first time a service has more reads than the database can serve, the right answer is cache the reads. The second time, the right answer is invalidate the cache without breaking everything. This chapter covers both with the .NET tools you already have - mostly five lines of Program.cs and one extension method.

When does adding a cache actually help?

Three signs the cache is justified.

Read amplification - the same data is read 10x more often than it changes. Product catalogue, user profiles, feature flags. The cache collapses 10 DB reads into 1 + 9 cache reads, and Redis is roughly 20x faster than Postgres for a key lookup.

Computed values that are expensive - a join across three tables, a permissions check, an ML inference. The DB can serve the inputs but the computation is the cost. Cache the result, not the inputs.

Variable load with a hot tail - 1% of items receive 90% of traffic (Zipfian distribution, the typical web shape). Caching the hot 1% keeps the DB under control during spikes.

The signs against caching are equally clear: low read/write ratio (cache hit rate will be terrible), strict freshness requirements (invalidation cost > DB cost), or a workload that already fits in the DB's own page cache (Postgres caches hot pages in RAM for free).

What numbers should I budget for cache decisions?

Operation                     Latency       QPS per node
IMemoryCache.Get              ~50 ns        millions
Redis GET (LAN)               ~0.5 ms       100K-1M
Postgres SELECT by PK         ~1-3 ms       30K-100K
Postgres SELECT with join     ~5-50 ms      <10K
DynamoDB GetItem              ~5-10 ms      depends on RCU

Rule of thumb: caching a Postgres PK lookup buys ~5x; caching a join buys ~50x; caching computed values can buy 1000x. Bigger wins justify more invalidation complexity.

What does the minimal cache-aside architecture look like?

The canonical loop, drawn:

flowchart LR
    Client --> App[ASP.NET Core]
    App -->|1. GET key| Cache[(Redis)]
    Cache -->|2a. hit| App
    Cache -->|2b. miss| App
    App -->|3. on miss<br/>SELECT| DB[(Postgres)]
    DB --> App
    App -->|4. SET key, TTL| Cache
    App --> Client

Every read tries the cache first; on miss, the app queries the DB and writes the result back with a TTL. Writes invalidate (or just let TTL expire) the relevant keys. This is the pattern in 90% of .NET services and it scales further than people expect.

What is the .NET 10 wiring for IDistributedCache + Redis?

Five lines of Program.cs:

builder.Services.AddStackExchangeRedisCache(opt =>
{
    opt.Configuration = builder.Configuration.GetConnectionString("Redis");
    opt.InstanceName  = "anhtu-dev:";
});

// Then in any service:
public class ProductService(
    IDistributedCache cache,
    AppDbContext db,
    ILogger<ProductService> log)
{
    public async Task<Product?> GetByIdAsync(int id, CancellationToken ct)
    {
        var key = $"product:{id}";
        var cached = await cache.GetStringAsync(key, ct);
        if (cached is not null)
            return JsonSerializer.Deserialize<Product>(cached);

        var product = await db.Products.FindAsync([id], ct);
        if (product is null) return null;

        await cache.SetStringAsync(
            key,
            JsonSerializer.Serialize(product),
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15) },
            ct);

        return product;
    }
}

Three details. The InstanceName prefix prevents key collisions across services sharing one Redis instance. AbsoluteExpiration is preferred over SlidingExpiration for predictability. JsonSerializer is the lightest option; for hot paths, MessagePack cuts payload by 3-5x.

How do I invalidate without breaking everything?

Three strategies, increasing in correctness and cost:

Always combine with TTL. Invalidation messages get lost; TTL is the backstop. The combo lets you ship without losing sleep.

What failure modes does caching introduce?

Four classic ones:

Chapter 13 shows how to expose cache_hits_total, cache_misses_total, and cache_size_bytes through OpenTelemetry so you watch all four.

When should you not add a cache?

When the underlying database can serve the load. Adding Redis introduces a deployment, a failure mode, and an invalidation contract. If the read QPS is below 1000 and your DB latency is already <10 ms, the cache buys nothing and costs operational complexity. Reach for it when the math from chapter 2 shows the DB cannot keep up, or when the join cost in chapter 5 becomes the bottleneck.

Where should you go from here?

Next chapter: database choice for .NET apps - how to decide between SQL and NoSQL, when read replicas help, and what sharding actually costs. Cache is what you reach for first; database choice is what decides whether you ever need to.

Frequently asked questions

When do I pick IMemoryCache over Redis?
When the data is small, identical across replicas, and tolerates being warmed independently per box. Examples: feature flags, in-process counters, small lookup tables. Once you have multi-instance deployment, anything that must be consistent between replicas - inventory, rate-limiter state, session - belongs in Redis behind IDistributedCache.
Cache-aside or write-through - which is the default?
Cache-aside, almost always. Write-through couples your write path to the cache and creates failure modes when Redis hiccups. Cache-aside accepts a small read after a write may miss the cache and pay the DB cost; that is fine. The exception is when you cannot afford the cold-read latency at all - then write-through pays.
What TTL should I set?
Set the longest TTL you can tolerate stale data for, then halve it. For session state: minutes. For product catalogue: hours. For 'top users this month': a day. The discipline is that every cache entry must have an absolute expiry - relying on invalidation alone leaks memory and creates ghost keys nobody can purge.
How do I detect a cache stampede?
Watch the DB latency p99 and the Redis miss-per-second metric together. A stampede shows up as a brief drop in cache hit rate paired with a spike in DB connection count and query latency. The fix is single-flight locking - the first request that misses populates the cache while others wait - or pre-warm the hot keys before they expire.