Introduction: The Cache Stampede That Taught Us Everything
Early in our cloud migration, we deployed a new feature with a 30-minute cache TTL. At minute 31, the cache expired. Because we hadn't implemented cache locking, 5,000 concurrent requests simultaneously missed the cache and hammered our primary database. The database CPU spiked to 100%, connections timed out, and the entire application went down for 12 minutes.
Redis is the industry standard for distributed caching in .NET, but simply swapping IMemoryCache for IDistributedCache isn't enough. Without proper serialization, expiration strategies, and stampede prevention, Redis can introduce new failure modes. This guide covers the production-tested patterns we use to keep our .NET APIs fast and our databases safe.

When to Choose Redis Over IMemoryCache
Not every application needs Redis. Adding it introduces network latency, infrastructure cost, and operational complexity. Use this decision matrix:
| Scenario | Recommended Cache | Why? |
|---|---|---|
| Single-server app, low traffic | IMemoryCache | Zero network overhead, simplest setup |
| Multiple pods/servers, shared state | Redis | Prevents cache divergence and redundant DB hits |
| High read/write ratio (>10:1) | Redis | Offloads massive read pressure from the primary database |
| Session state in web farms | Redis | Required for sticky-session-free horizontal scaling |
| Sub-millisecond latency critical | IMemoryCache + Redis | Local L1 cache for hot data, Redis as L2 |
Production-Ready Redis Configuration
The default AddStackExchangeRedisCache setup is fine for local development, but production requires tuning the underlying StackExchange.Redis connection pool to prevent timeout exceptions under load.
builder.Services.AddStackExchangeRedisCache(options =>
{
// Use ConfigurationOptions for fine-grained control
var configOptions = ConfigurationOptions.Parse(
builder.Configuration.GetConnectionString("Redis"));
// CRITICAL: Prevent app startup failure if Redis is temporarily down
configOptions.AbortOnConnectFail = false;
// Optimize connection pool for high-throughput APIs
configOptions.ConnectionTimeout = 2000;
configOptions.SyncTimeout = 2000;
configOptions.AsyncTimeout = 2000;
// Use a single multiplexed connection per app instance
options.ConfigurationOptions = configOptions;
options.InstanceName = "MyApp:Prod:"; // Namespace your keys
});
💡 Connection Multiplexing Pro Tip
StackExchange.Redis is designed to use a single multiplexed connection per application instance. Do not create a new ConnectionMultiplexer per request. The AddStackExchangeRedisCache extension handles this pooling automatically if configured correctly.
Safe Caching Pattern: Preventing the Thundering Herd
A naive GetOrCreate implementation leaves you vulnerable to cache stampedes. When a popular key expires, hundreds of threads will simultaneously query the database. Here is a resilient pattern using double-checked locking:
public async Task<T?> GetOrCreateAsync<T>(
string key,
Func<Task<T>> factory,
TimeSpan absoluteExpiration,
CancellationToken ct = default)
{
// 1. Try to get from cache first
var cached = await _cache.GetStringAsync(key, ct);
if (cached != null)
{
return JsonSerializer.Deserialize<T>(cached);
}
// 2. Cache miss: Acquire a distributed lock to prevent stampede
var lockKey = $"lock:{key}";
var lockValue = Guid.NewGuid().ToString();
// SETNX equivalent: only one thread wins the lock
var lockAcquired = await _cache.SetStringAsync(
lockKey,
lockValue,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(5)
},
ct);
if (lockAcquired)
{
try
{
// 3. Double-check cache after acquiring lock (another thread might have populated it)
cached = await _cache.GetStringAsync(key, ct);
if (cached != null)
{
return JsonSerializer.Deserialize<T>(cached);
}
// 4. Fetch from database and populate cache
var data = await factory();
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = absoluteExpiration,
// Add jitter to prevent simultaneous expiration of related keys
SlidingExpiration = TimeSpan.FromMinutes(5)
};
await _cache.SetStringAsync(
key,
JsonSerializer.Serialize(data),
options,
ct);
return data;
}
finally
{
// 5. Release lock (only if we still own it)
var currentLock = await _cache.GetStringAsync(lockKey, ct);
if (currentLock == lockValue)
{
await _cache.RemoveAsync(lockKey, ct);
}
}
}
else
{
// 6. Lock failed: another thread is fetching. Wait briefly and try cache again.
await Task.Delay(100, ct);
cached = await _cache.GetStringAsync(key, ct);
return cached != null ? JsonSerializer.Deserialize<T>(cached) : default;
}
}
Serialization: The Hidden Performance Killer
How you serialize data to Redis dramatically impacts CPU usage and memory footprint. We learned this the hard way when caching large Entity Framework proxy objects.
- ●
Never cache EF Core entities: They contain lazy-loading proxies and circular references that bloat payload size. Always map to lightweight DTOs first.
- ●
Use
System.Text.Jsonwith options: ConfigureJsonSerializerOptionswithPropertyNameCaseInsensitive = trueand a customJsonStringEnumConverterfor predictable results. - ●
Consider MessagePack for high-throughput: If you are caching millions of small objects, MessagePack is 2-3x faster and produces smaller payloads than JSON, though it sacrifices human readability.
- ●
Compress large payloads: For payloads >10KB, use Brotli or GZip compression before calling
SetStringAsync.
Redis Eviction Policies: What Happens When Memory is Full?
Redis is an in-memory store. If you don't configure maxmemory, it will eventually consume all available RAM and crash (or be OOM-killed by the OS). In production, you must set both a memory limit and an eviction policy.
| Eviction Policy | Behavior | Best For |
|---|---|---|
| noeviction | Returns errors on writes when full | Redis used purely as a database (rare for caching) |
| allkeys-lru | Evicts least recently used keys | General purpose caching (Recommended) |
| volatile-lru | Evicts LRU keys with an expiration set | Mixed cache/persistent data |
| volatile-ttl | Evicts keys with the shortest time-to-live | When TTL is a strong indicator of value |
Configuration Tip: In your redis.conf (or Kubernetes Helm chart), always set maxmemory 1gb (or appropriate limit) and maxmemory-policy allkeys-lru. This ensures Redis gracefully forgets old data rather than crashing your application.
Cache Invalidation: The Hard Part
There are only two hard things in Computer Science: cache invalidation and naming things. Relying solely on TTLs can lead to stale data. For critical data, implement proactive invalidation:
public async Task UpdateProductAsync(ProductDto product, CancellationToken ct)
{
// 1. Update database
await _repository.UpdateAsync(product, ct);
// 2. Invalidate specific cache key
var cacheKey = $"product:{product.Id}";
await _cache.RemoveAsync(cacheKey, ct);
// 3. (Optional) Invalidate list caches that might contain this product
await _cache.RemoveAsync("products:featured:list", ct);
// 4. Publish event for other services to invalidate their local caches
await _eventPublisher.PublishAsync(new ProductUpdatedEvent(product.Id), ct);
}
Monitoring Redis in Production
You cannot optimize what you do not measure. We monitor these key metrics via Prometheus and Grafana:
- ●
Hit/Miss Ratio: Target >85%. A sudden drop indicates a deployment wiped the cache or a key naming change.
- ●
Memory Fragmentation Ratio: If
mem_fragmentation_ratio> 1.5, Redis is struggling with memory allocation. Consider restarting the pod during low traffic. - ●
Evicted Keys Rate: A high rate means your
maxmemoryis too low or TTLs are too long. - ●
Connected Clients: Should be stable (1 per application instance). Spikes indicate connection leaks.
- ●
Command Latency (P99): Should be <2ms. Higher values indicate network issues or slow commands (like
KEYS *, which you should never use in production).
Common Production Mistakes
- ●
❌ Using
KEYS *in production: This blocks the single-threaded Redis server. UseSCANinstead. - ●
❌ Ignoring TLS in cloud environments: Always enable SSL/TLS for Redis connections outside a trusted VPC.
- ●
❌ Caching HTML fragments: Cache data (JSON/DTOs), let the frontend or BFF handle rendering. It's more flexible and compresses better.
- ●
❌ Hardcoding Redis connection strings: Always use Kubernetes Secrets or Azure Key Vault.
- ●
❌ Assuming Redis is persistent: By default, Redis is ephemeral. Do not use it as your primary data store.
"Treat Redis as a volatile performance optimization, not a durable data store. Your database is the source of truth; Redis is just a very fast, temporary mirror."
Frequently Asked Questions
How do I handle Redis connection drops in .NET?
What is the maximum size of a value in Redis?
Should I use Redis Cluster or a single instance?
How do I clear the entire cache for a specific entity type?
Conclusion
Redis is a transformative tool for .NET performance, but it demands respect. By implementing robust connection pooling, preventing cache stampedes with distributed locks, serializing lightweight DTOs, and monitoring eviction metrics, you can build a caching layer that scales effortlessly. Remember: the best cache is the one that gracefully degrades to the database when it fails.
Want to dive deeper into .NET performance? Check out our guides on [Preventing Entity Framework N+1 Queries], [Distributed Locking Patterns in .NET], and [Optimizing ASP.NET Core Startup Time].
