← Back to Articles
.NET 8+ ASP.NET Core Performance Redis Distributed Cache System Architecture Kubernetes Scaling

Redis Distributed Caching in .NET: Production Patterns, Pitfalls, and Performance Tuning

IMemoryCache isn't enough for scaled .NET applications. Learn how to implement Redis distributed caching safely: preventing cache stampedes, optimizing serialization, handling connection drops, and tuning for production Kubernetes environments.

Jun 2026
16 min read
Written by Engineering Team

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.

ASP.NET Core microservices sharing a centralized Redis cluster
A centralized Redis cluster ensures cache consistency across all Kubernetes pods, but requires careful connection management.

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.

Program.cs
csharp
              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:

ResilientCacheService.cs
csharp
              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.Json with options: Configure JsonSerializerOptions with PropertyNameCaseInsensitive = true and a custom JsonStringEnumConverter for 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:

CacheInvalidation.cs
csharp
              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 maxmemory is 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. Use SCAN instead.

  • 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?
StackExchange.Redis automatically handles reconnection. Ensure `AbortOnConnectFail = false` in your configuration so your app doesn't crash on startup if Redis is briefly unavailable. Your cache miss logic should gracefully fall back to the database.
What is the maximum size of a value in Redis?
Redis can store values up to 512MB. However, for performance reasons, keep cached values under 100KB. Large values increase network latency and memory fragmentation.
Should I use Redis Cluster or a single instance?
For most .NET applications, a single master with one replica (for high availability) is sufficient. Only move to Redis Cluster (sharding) when your dataset exceeds the memory of a single node or you need >100k operations per second.
How do I clear the entire cache for a specific entity type?
Redis doesn't support wildcard deletions efficiently. Instead of `DEL user:*`, use a cache versioning strategy: change the `InstanceName` or key prefix (e.g., `user:v2:`) in your configuration, which naturally invalidates all old keys as they expire.

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].

We use cookies to improve your experience.