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
- When does adding a cache actually help?
- What numbers should I budget for cache decisions?
- What does the minimal cache-aside architecture look like?
- What is the .NET 10 wiring for IDistributedCache + Redis?
- How do I invalidate without breaking everything?
- What failure modes does caching introduce?
- When should you not add a cache?
- 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:
- TTL only - simplest, accept staleness equal to TTL. Works for read-heavy, low-correctness data.
- Write-through invalidation - on every write, delete the cache key. Risk: if the deletion fails, cache stays stale until TTL.
- Write-through with publish/subscribe - on write, publish a key to a Redis channel; all replicas listen and invalidate their local IMemoryCache too. Only needed when you have a two-tier cache.
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:
- Cache stampede - many requests miss the same expired key simultaneously and all hit the DB. Fix: single-flight locking (only one request populates) or pre-warm the key before expiry.
- Stale-after-write - write succeeds, cache invalidation fails silently. Fix: structured logging + alerting on invalidation failures; rely on TTL as a backstop.
- Hot key - one Redis key serves so much traffic it saturates
the Redis CPU. Fix: shard the key (
product:{id}:{shard%4}) or pre-fetch into IMemoryCache per box. - Memory bloat - cache grows without bound because every entry
has long TTL and nothing evicts. Fix: configure
maxmemory-policy allkeys-lruon Redis; never rely solely on TTL for capacity.
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.