.
Step 1: Infrastructure & Package Setup
Install the Redis-backed distributed cache provider and configure connection multiplexing. StackExchange.Redis is the industry standard for .NET due to its async-first design and connection pooling.
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
dotnet add package Microsoft.Extensions.Hosting
Step 2: DI Registration & Configuration
Configure IDistributedCache with connection resiliency, key prefixing, and serialization options. Avoid default JSON serialization if cross-service compatibility is not required; UTF8 JSON is preferred for internal microservices.
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "app-";
options.ConfigurationOptions = new StackExchange.Redis.ConfigurationOptions
{
AbortOnConnectFail = false,
ConnectTimeout = 3000,
SyncTimeout = 1000,
KeepAlive = 2
};
});
Step 3: Cache-Aside with Async Stampede Protection
The cache-aside pattern loads data on miss and caches it for subsequent requests. Without protection, concurrent cache misses trigger identical database queries (thundering herd). Use SemaphoreSlim per key or a distributed lock for high-concurrency scenarios.
public class CachedRepository
{
private readonly IDistributedCache _cache;
private readonly ILogger<CachedRepository> _logger;
private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();
public CachedRepository(IDistributedCache cache, ILogger<CachedRepository> logger)
{
_cache = cache;
_logger = logger;
}
public async Task<T?> GetOrSetAsync<T>(string key, Func<Task<T>> factory, TimeSpan absoluteExpiration)
{
var cached = await _cache.GetAsync(key, CancellationToken.None);
if (cached is not null)
{
return JsonSerializer.Deserialize<T>(cached);
}
var lockObj = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await lockObj.WaitAsync();
try
{
// Double-check after acquiring lock
cached = await _cache.GetAsync(key, CancellationToken.None);
if (cached is not null) return JsonSerializer.Deserialize<T>(cached);
var value = await factory();
if (value is null) return default;
var serialized = JsonSerializer.SerializeToUtf8Bytes(value);
await _cache.SetAsync(key, serialized, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = absoluteExpiration,
SlidingExpiration = TimeSpan.FromMinutes(5)
});
return value;
}
finally
{
lockObj.Release();
_locks.TryRemove(key, out _);
}
}
}
Step 4: Key Design & Serialization Strategy
Key collisions are a silent production failure. Enforce a strict naming convention: {service}:{version}:{entity}:{id}. Versioning prevents deserialization failures after schema changes. Use JsonSerializerOptions with PropertyNameCaseInsensitive = true and DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull to reduce payload size.
For internal services where performance is critical, switch to System.Text.Json with JsonSerializerDefaults.Web or adopt MessagePack via MessagePack-CSharp. Binary serialization reduces CPU overhead by 40β60% compared to JSON for large object graphs.
Step 5: Graceful Degradation & Fallback
Distributed caches are not guaranteed to be available. Wrap cache calls in a fallback strategy that bypasses the cache on timeout or connection failure. Use Polly or built-in IHttpClientFactory-style resilience for cache operations.
public async Task<T?> GetWithFallbackAsync<T>(string key, Func<Task<T>> factory)
{
try
{
return await GetOrSetAsync(key, factory, TimeSpan.FromMinutes(10));
}
catch (Exception ex) when (ex is RedisException or TimeoutException)
{
_logger.LogWarning(ex, "Cache unavailable, falling back to direct fetch for key {Key}", key);
return await factory();
}
}
Architecture Decisions & Rationale
- Cache-Aside over Write-Through: Write-through adds write latency and complexity. Cache-aside keeps writes simple and defers cache population to read paths, which aligns with read-heavy workloads.
- Async Locks per Key: Prevents thundering herd without blocking unrelated cache operations. In-memory locks are sufficient for single-node deployments; use Redis
SETNX with TTL for multi-region scenarios.
- TTL Stratification: Uniform TTLs cause cache expiration spikes. Add jitter (
TimeSpan.FromMinutes(10) + Random.Next(0, 60)) to stagger invalidation.
- Serialization Boundary: JSON ensures cross-language compatibility. Binary serialization is reserved for homogeneous .NET ecosystems where performance outweighs interoperability.
Pitfall Guide
1. Synchronous Blocking on Async Cache Calls
Calling .Result or .Wait() on IDistributedCache methods deadlocks ASP.NET Core request contexts and exhausts thread pool threads. Always use await and propagate CancellationToken.
2. Cache Stampede Without Locking
Concurrent cache misses trigger identical database queries. The in-memory SemaphoreSlim pattern mitigates this for single instances. For distributed stampede protection, implement SETNX with a short TTL or use HybridCache (available in .NET 9+ previews) which includes built-in stampede resolution.
3. Serialization Versioning Blindness
Adding, removing, or renaming properties breaks deserialization of cached payloads. Version your cache keys (v2:user:123) and implement a cache busting strategy during deployments. Use JsonSerializerOptions with PropertyNameCaseInsensitive and explicit contract resolvers to tolerate schema drift.
4. Unbounded Key Proliferation
Caching every unique query parameter creates memory fragmentation and eviction storms. Implement key normalization: hash complex query strings, enforce maximum key length (64 bytes), and use Redis SCAN with pattern matching for cleanup. Monitor used_memory and evicted_keys metrics.
5. Ignoring Cache-Aside vs. Write-Through Tradeoffs
Cache-aside is optimal for read-heavy workloads. Write-heavy or strict-consistency requirements demand write-through or cache-aside with explicit invalidation. Failing to align the pattern with workload characteristics causes stale data or excessive cache churn.
6. Missing Cache Health Monitoring
Distributed caches fail silently under network partitions or memory pressure. Instrument IDistributedCache calls with OpenTelemetry spans. Track cache.hit, cache.miss, cache.error, and cache.latency. Alert on hit ratio drops below 60% or eviction rates exceeding 100 keys/sec.
7. Over-Caching Low-Value Data
Caching frequently updated, low-read data wastes memory and increases invalidation overhead. Apply the 80/20 rule: cache only data with >10 reads per write and <5% update frequency. Use cache tags or logical grouping for bulk invalidation instead of individual key deletion.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Read-heavy API (>90% reads, <10% writes) | Cache-Aside + Local/Distributed Hybrid | Maximizes hit ratio, minimizes DB load, tolerates cache unavailability | Low infrastructure, moderate dev effort |
| Write-heavy transactional data | Write-Through or Cache-Aside with explicit invalidation | Prevents stale data, maintains consistency across nodes | Higher write latency, requires invalidation logic |
| Multi-region deployment | Distributed Cache + Redis Sentinel/Cluster + Async Locks | Ensures state consistency across geographic boundaries, handles network partitions | High infrastructure cost, requires latency tuning |
| Ephemeral session/state data | In-Memory + Sticky Sessions or Distributed Cache with short TTL | Avoids unnecessary network hops, aligns with stateless compute model | Minimal cost, scales with instance count |
Configuration Template
appsettings.json
{
"ConnectionStrings": {
"Redis": "localhost:6379,abortConnect=false,connectTimeout=3000,syncTimeout=1000,keepAlive=2"
},
"Cache": {
"DefaultTtlMinutes": 10,
"TtlJitterSeconds": 30,
"KeyPrefix": "prod-app-v1-",
"FallbackEnabled": true
}
}
Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = builder.Configuration["Cache:KeyPrefix"];
options.ConfigurationOptions = StackExchange.Redis.ConfigurationOptions.Parse(options.Configuration);
});
builder.Services.AddSingleton<CachedRepository>();
Quick Start Guide
- Install packages:
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
- Add Redis connection string to
appsettings.json and register cache in Program.cs using AddStackExchangeRedisCache
- Implement a
CachedRepository with GetOrSetAsync using IDistributedCache, SemaphoreSlim, and JsonSerializer
- Replace direct data access calls with
CachedRepository.GetOrSetAsync, apply TTL jitter, and monitor cache hit ratios via Application Insights or OpenTelemetry
Distributed caching in .NET is not a configuration toggle; it is an architectural contract. Treat the cache as a shared state bus, enforce deterministic key and serialization boundaries, protect against stampede and partition failures, and instrument everything. The performance gains compound only when the implementation aligns with distributed system realities.