Problem Context
A request that hits your database every time scales linearly with your user count and dies the moment a celebrity tweets about you. A cache turns that "every request" into "one request per cache miss" β the difference between a 10x and a 1000x system. But caches are also the source of the most embarrassing production bugs in the industry: stale data shown to the wrong user, thundering herds taking down the database the cache was supposed to protect, and the "one of the two hard problems" clichΓ© about cache invalidation existing because it's genuinely hard.
This is the working caching playbook in 2026: where to put the cache, which strategy to use, what TTLs mean, and how to avoid the four failure modes that show up in every postmortem.
- You added Redis "to make it faster" without measuring what to cache
- Your cache and your DB disagree and nobody knows who's right
- Your Redis went down and the DB followed five minutes later
- You can't explain the difference between cache-aside, write-through, and write-behind
This is the caching mental model that survives a real incident β placement, strategies, invalidation, and the four classes of cache failure.
Concept Explanation
Caches live at five layers, and the cheapest cache is the one closest to the user:
- Browser / CDN edge β milliseconds. Best for immutable assets and short-TTL HTML.
- Reverse proxy (nginx, Front Door) β milliseconds. Cache full HTTP responses keyed by URL + Vary headers.
- Application memory(in-process LRU, MemoryCache) β sub-microsecond. No network hop, but doesn't survive restarts and isn't shared.
- Distributed cache (Redis, Memcached) β sub-millisecond. Shared across instances, durable across restarts.
- Database query/result cacheβ last resort. The DB already has buffer pools; reach for this only when you can't move logic.
flowchart LR
U["User"] --> CDN["CDN / Edge<br/>cache: assets, HTML"]
CDN --> LB["Reverse Proxy<br/>cache: GET /api/popular"]
LB --> APP["App<br/>L1: in-process LRU"]
APP --> R["Redis<br/>L2: shared cache"]
R --> DB["Database<br/>buffer pool"]
APP -.->|miss| R
R -.->|miss| DB
DB -.->|fill| R
R -.->|fill| APP
style R fill:#dc2626,color:#fff,stroke:#b91c1c
style DB fill:#0078D4,color:#fff,stroke:#005a9e
Implementation
Step 1: Pick a strategy β cache-aside is the default
// Cache-aside (lazy-loading): app owns the cache; DB is the source of truth.
public async Task<Product?> GetProductAsync(int id)
{
var key = $"product:{id}";
var cached = await cache.GetStringAsync(key);
if (cached is not null)
return JsonSerializer.Deserialize<Product>(cached);
var product = await db.Products.FindAsync(id);
if (product is not null)
{
await cache.SetStringAsync(key, JsonSerializer.Serialize(product),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });
}
return product;
}The other strategies in one paragraph each: Write-through writes to cache + DB synchronously β strong consistency, slower writes. Write-behind writes to cache, async-flushes to DB β fastest writes, risk of data loss on crash.Read-through hides the cache from app code behind a loader β clean abstraction but you give up control over what gets cached.
Step 2: Invalidation β explicit, on the write path
public async Task UpdatePriceAsync(int productId, decimal newPrice)
{
await db.Products.Where(p => p.Id == productId)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, newPrice));
// Invalidate every key that depended on this product
await cache.RemoveAsync($"product:{productId}");
await cache.RemoveAsync($"category:{categoryId}:top10"); // derived view
}TTL is your safety net, not your invalidation strategy. Use tag-based invalidation (HybridCache, Redis hash tags, or CacheManager) when one write should bust many keys.
Step 3: Use HybridCache (.NET 9+) for two-level caching
// Program.cs β single API for L1 (in-memory) + L2 (Redis), with stampede protection
builder.Services.AddStackExchangeRedisCache(o =>
o.Configuration = builder.Configuration.GetConnectionString("Redis"));
builder.Services.AddHybridCache(options =>
{
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10), // L2 (Redis) absolute
LocalCacheExpiration = TimeSpan.FromMinutes(1) // L1 (in-process)
};
});
// Usage β single GetOrCreateAsync; HybridCache deduplicates concurrent misses
public Task<Product?> GetAsync(int id, CancellationToken ct) =>
cache.GetOrCreateAsync(
key: $"product:{id}",
factory: async ct => await db.Products.FindAsync([id], ct),
cancellationToken: ct);Step 4: TTL design β three time horizons
Volatile data (prices, stock): 30s β 2 min
Profile / catalog (semi-static): 5 β 30 min
Reference data (zip codes, lookups): hours β days (or refresh on deploy)
Add 10β20% jitter to TTLs to avoid synchronized expiration ("herd").Step 5: HTTP-layer caching with the right headers
Cache-Control: public, max-age=300, stale-while-revalidate=60
ETag: "v=42"
Vary: Accept-Encoding, Accept-Language
# stale-while-revalidate lets the CDN serve stale for 60s while it
# refetches in the background β zero user-visible latency on revalidation.Step 6: Stampede protection β request coalescing
// Without coalescing, 1000 concurrent requests on a cold key β 1000 DB queries.
// HybridCache coalesces by default. Manual pattern with SemaphoreSlim:
private static readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();
public async Task<T> GetOrLoadAsync<T>(string key, Func<Task<T>> loader)
{
if (await TryGet<T>(key) is { } hit) return hit;
var sem = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await sem.WaitAsync();
try
{
if (await TryGet<T>(key) is { } afterWait) return afterWait; // double-check
var value = await loader();
await Set(key, value);
return value;
}
finally
{
sem.Release();
}
}Pitfalls
1. Thundering herd / cache stampede. A popular key expires; thousands of requests miss simultaneously and all hit the DB. Solutions: request coalescing (HybridCache does this), early refresh (refresh at 80% of TTL, not at 100%), and TTL jitter.
2. Cache penetration β the "not found" flood. A bot probes for non-existent product IDs; every request misses, every request hits DB. Cache the negative result with a short TTL (cache.Set(key, null, 30s)) or use a Bloom filter.
3. The cache is now load-bearing.When Redis goes down, the DB takes the full traffic and dies. Add a circuit breaker and an L1 in-process cache so a Redis outage degrades, doesn't cascade.
4. Stale data after a write. You wrote to DB, but the cache still has the old value for 10 minutes. Either invalidate on write (cache-aside + explicit Remove) or write-through. TTL alone is a guess.
5. Caching personalised data behind a shared key. GET /api/recommendations served from CDN with noVary: Authorizationships user A's recs to user B. Either don't cache, or include the user id in the key.
6. Keys without a versioning strategy. You change the shape of the cached object and old entries deserialize to null / crash. Prefix keys with a schema version (product:v2:42) and bump on shape changes.
Practical Takeaways
- Default to cache-aside. Switch only when you have a specific reason.
- L1 (in-memory) + L2 (Redis) via HybridCache is the modern .NET default β one API, stampede-safe.
- Invalidate on the write path. TTL is the safety net, not the strategy.
- Add jitter to TTLs (10β20%) to avoid synchronized expiration.
- Cache the "not found" result with a short TTL to defeat penetration probes.
- Always have a fallback for the cache being down β circuit breaker + in-memory L1.
- Version your keys. The day you change the DTO shape you'll be glad you did.

