.NET Core

Caching Strategies in .NET: In-Memory, Distributed, and Redis

14 min readFebruary 9, 2026Updated Mar 9, 2026
.NET cachingRedis .NETIn-memory cache C#Distributed cache .NETIMemoryCacheIDistributedCache.NET performanceCache invalidation

Caching is one of the highest-impact optimizations in backend systems — but only when consistency and invalidation rules are explicitly designed. In high-traffic APIs, I've seen caching reduce average response latency from 200ms down to under 5ms, while cutting database load by over 80%. The trick is knowing which layer to cache at and when to let the cache go stale.

This article walks through every caching mechanism available in modern .NET, with real code examples you can drop into production.

Cache Types and Trade-offs

In-Memory Cache with IMemoryCache

The simplest and fastest option. Data lives in the application process memory, so there's zero serialization overhead and no network hops. The downside: each instance of your app has its own isolated cache, so this doesn't work well behind a load balancer with multiple replicas.

csharp
public class ProductService
{
    private readonly IMemoryCache _cache;
    private readonly IProductRepository _repository;

    public ProductService(IMemoryCache cache, IProductRepository repository)
    {
        _cache = cache;
        _repository = repository;
    }

    public async Task<Product?> GetProductAsync(int id)
    {
        var cacheKey = $"product:{id}";

        if (_cache.TryGetValue(cacheKey, out Product? cached))
            return cached;

        var product = await _repository.GetByIdAsync(id);

        if (product is not null)
        {
            var options = new MemoryCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
                SlidingExpiration = TimeSpan.FromMinutes(2),
                Size = 1,
                Priority = CacheItemPriority.High
            };

            options.RegisterPostEvictionCallback((key, value, reason, state) =>
            {
                // Log eviction for monitoring
            });

            _cache.Set(cacheKey, product, options);
        }

        return product;
    }
}

Register it in Program.cs:

csharp
builder.Services.AddMemoryCache(options =>
{
    options.SizeLimit = 1024; // Max number of cache entries
});

A common mistake I see: people skip SizeLimit entirely. Without it, the cache grows unbounded and you'll eventually run into memory pressure in production. Always set a size limit and assign Size to each entry.

Distributed Cache with IDistributedCache and Redis

When you're running multiple instances behind a load balancer, you need a shared cache. IDistributedCache is .NET's abstraction for this, and Redis is the most common backing store.

csharp
// Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "myapp:";
});
csharp
public class CatalogService
{
    private readonly IDistributedCache _cache;
    private readonly ICatalogRepository _repository;
    private readonly ILogger<CatalogService> _logger;

    public CatalogService(
        IDistributedCache cache,
        ICatalogRepository repository,
        ILogger<CatalogService> logger)
    {
        _cache = cache;
        _repository = repository;
        _logger = logger;
    }

    public async Task<List<Category>> GetCategoriesAsync(CancellationToken ct = default)
    {
        var cacheKey = "categories:all";

        var cached = await _cache.GetStringAsync(cacheKey, ct);
        if (cached is not null)
        {
            return JsonSerializer.Deserialize<List<Category>>(cached)!;
        }

        var categories = await _repository.GetAllCategoriesAsync(ct);

        var options = new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1),
            SlidingExpiration = TimeSpan.FromMinutes(15)
        };

        await _cache.SetStringAsync(
            cacheKey,
            JsonSerializer.Serialize(categories),
            options,
            ct);

        return categories;
    }
}

One thing to watch out for: IDistributedCache serializes everything as byte[] or strings. This means you pay a serialization cost on every read and write. For hot paths, consider using the StackExchange.Redis IConnectionMultiplexer directly to avoid the abstraction overhead.

Output Caching (.NET 7+)

Output caching sits at the middleware level and caches entire HTTP responses. It's powerful for read-heavy public endpoints where the response doesn't change per user.

csharp
// Program.cs
builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(builder => builder.Expire(TimeSpan.FromMinutes(5)));

    options.AddPolicy("CatalogPolicy", builder =>
        builder
            .Expire(TimeSpan.FromMinutes(30))
            .Tag("catalog")
            .SetVaryByQuery("page", "sort"));
});

app.UseOutputCache();
csharp
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet]
    [OutputCache(PolicyName = "CatalogPolicy")]
    public async Task<IActionResult> GetProducts(
        [FromQuery] int page = 1,
        [FromQuery] string sort = "name")
    {
        var products = await _service.GetProductsAsync(page, sort);
        return Ok(products);
    }

    [HttpPost]
    public async Task<IActionResult> CreateProduct(
        CreateProductDto dto,
        IOutputCacheStore cacheStore)
    {
        var product = await _service.CreateAsync(dto);

        // Invalidate all responses tagged with "catalog"
        await cacheStore.EvictByTagAsync("catalog", default);

        return CreatedAtAction(nameof(GetProducts), new { id = product.Id }, product);
    }
}

HybridCache (.NET 9)

.NET 9 introduced HybridCache, which combines in-memory and distributed caching into a single API. It handles serialization, stampede prevention, and two-level caching automatically. This is the approach I recommend for new projects.

csharp
// Program.cs
builder.Services.AddHybridCache(options =>
{
    options.MaximumPayloadBytes = 1024 * 1024; // 1 MB
    options.MaximumKeyLength = 256;
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromMinutes(30),
        LocalCacheExpiration = TimeSpan.FromMinutes(5)
    };
});

// Also register a distributed cache backend
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "localhost:6379";
});
csharp
public class OrderService
{
    private readonly HybridCache _cache;
    private readonly IOrderRepository _repository;

    public OrderService(HybridCache cache, IOrderRepository repository)
    {
        _cache = cache;
        _repository = repository;
    }

    public async Task<OrderSummary> GetOrderSummaryAsync(
        int orderId, CancellationToken ct = default)
    {
        return await _cache.GetOrCreateAsync(
            $"order:{orderId}",
            async token => await _repository.GetSummaryAsync(orderId, token),
            new HybridCacheEntryOptions
            {
                Expiration = TimeSpan.FromMinutes(10),
                LocalCacheExpiration = TimeSpan.FromMinutes(2)
            },
            cancellationToken: ct);
    }

    public async Task InvalidateOrderAsync(int orderId)
    {
        await _cache.RemoveAsync($"order:{orderId}");
    }
}

HybridCache checks the local in-memory cache first, then falls back to the distributed cache, and only hits the factory delegate if both miss. It also coalesces concurrent requests for the same key, which eliminates cache stampede by design.

The Cache-Aside Pattern in Depth

Cache-aside (also called lazy-loading) is the most widely used pattern. The application checks the cache first, and on a miss, loads from the source and populates the cache.

csharp
public class CacheAsideService<T> where T : class
{
    private readonly IDistributedCache _cache;
    private readonly ILogger _logger;
    private readonly JsonSerializerOptions _jsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
    };

    public CacheAsideService(IDistributedCache cache, ILogger logger)
    {
        _cache = cache;
        _logger = logger;
    }

    public async Task<T?> GetOrSetAsync(
        string key,
        Func<Task<T?>> factory,
        TimeSpan? absoluteExpiration = null,
        TimeSpan? slidingExpiration = null,
        CancellationToken ct = default)
    {
        // 1. Try cache
        var cached = await _cache.GetStringAsync(key, ct);
        if (cached is not null)
        {
            _logger.LogDebug("Cache HIT for {Key}", key);
            return JsonSerializer.Deserialize<T>(cached, _jsonOptions);
        }

        _logger.LogDebug("Cache MISS for {Key}", key);

        // 2. Load from source
        var value = await factory();
        if (value is null) return null;

        // 3. Populate cache
        var options = new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = absoluteExpiration ?? TimeSpan.FromMinutes(10),
            SlidingExpiration = slidingExpiration ?? TimeSpan.FromMinutes(2)
        };

        var serialized = JsonSerializer.Serialize(value, _jsonOptions);
        await _cache.SetStringAsync(key, serialized, options, ct);

        return value;
    }

    public async Task InvalidateAsync(string key, CancellationToken ct = default)
    {
        await _cache.RemoveAsync(key, ct);
        _logger.LogInformation("Cache INVALIDATED for {Key}", key);
    }
}

The risk with cache-aside is that your data source and cache can drift apart. If another service or a direct database update changes the data, your cache won't know about it. That's where invalidation strategies come in.

Cache Invalidation Strategies

Phil Karlton famously said there are only two hard things in computer science: cache invalidation and naming things. Here are the strategies that actually work in production.

Time-Based Expiration (TTL)

The simplest approach. Set a TTL and accept that data might be stale for that duration. Good for data where eventual consistency is acceptable.

csharp
// Short TTL for frequently changing data
var stockOptions = new DistributedCacheEntryOptions
{
    AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30)
};

// Long TTL for rarely changing data
var configOptions = new DistributedCacheEntryOptions
{
    AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24),
    SlidingExpiration = TimeSpan.FromHours(6)
};

Event-Based Invalidation

When data changes, publish an event that triggers cache removal. This is the most reliable approach for distributed systems.

csharp
public class ProductUpdatedHandler : INotificationHandler<ProductUpdatedEvent>
{
    private readonly IDistributedCache _cache;
    private readonly IOutputCacheStore _outputCache;

    public ProductUpdatedHandler(
        IDistributedCache cache,
        IOutputCacheStore outputCache)
    {
        _cache = cache;
        _outputCache = outputCache;
    }

    public async Task Handle(
        ProductUpdatedEvent notification,
        CancellationToken ct)
    {
        // Remove specific entry
        await _cache.RemoveAsync($"product:{notification.ProductId}", ct);

        // Remove list caches that might include this product
        await _cache.RemoveAsync($"products:category:{notification.CategoryId}", ct);

        // Evict output cache responses
        await _outputCache.EvictByTagAsync("catalog", ct);
    }
}

Versioned Keys

Instead of deleting cache entries, change the key. This is useful when you want atomic switchover without any window of no-cache.

csharp
public class VersionedCacheService
{
    private readonly IDistributedCache _cache;
    private readonly IMemoryCache _versionCache;

    public async Task<string> GetVersionedKeyAsync(string baseKey)
    {
        var version = await GetCurrentVersionAsync(baseKey);
        return $"{baseKey}:v{version}";
    }

    public async Task InvalidateByVersionAsync(string baseKey)
    {
        var currentVersion = await GetCurrentVersionAsync(baseKey);
        var newVersion = currentVersion + 1;

        await _cache.SetStringAsync(
            $"{baseKey}:version",
            newVersion.ToString(),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(7)
            });

        // Old versioned entries will expire naturally via TTL
    }

    private async Task<long> GetCurrentVersionAsync(string baseKey)
    {
        var version = await _cache.GetStringAsync($"{baseKey}:version");
        return version is not null ? long.Parse(version) : 1;
    }
}

Cache Stampede Prevention

A cache stampede happens when a popular cache entry expires and dozens (or thousands) of concurrent requests all miss the cache simultaneously, hammering the database. I've seen this take down a database during a traffic spike — the cache expired at the worst possible moment.

Locking with SemaphoreSlim

csharp
public class StampedeProtectedCache<T> where T : class
{
    private readonly IDistributedCache _cache;
    private static readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();

    public async Task<T?> GetOrCreateAsync(
        string key,
        Func<Task<T?>> factory,
        TimeSpan expiration,
        CancellationToken ct = default)
    {
        // Try cache first without locking
        var cached = await _cache.GetStringAsync(key, ct);
        if (cached is not null)
            return JsonSerializer.Deserialize<T>(cached);

        // Acquire a per-key lock
        var keyLock = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));

        await keyLock.WaitAsync(ct);
        try
        {
            // Double-check after acquiring lock
            cached = await _cache.GetStringAsync(key, ct);
            if (cached is not null)
                return JsonSerializer.Deserialize<T>(cached);

            // Only one request reaches here
            var value = await factory();
            if (value is not null)
            {
                await _cache.SetStringAsync(
                    key,
                    JsonSerializer.Serialize(value),
                    new DistributedCacheEntryOptions
                    {
                        AbsoluteExpirationRelativeToNow = expiration
                    },
                    ct);
            }

            return value;
        }
        finally
        {
            keyLock.Release();
        }
    }
}

Jittered Expiration

Add randomness to TTL values so entries don't all expire at the same time.

csharp
public static class CacheExpirationExtensions
{
    private static readonly Random _jitter = new();

    public static DistributedCacheEntryOptions WithJitter(
        this DistributedCacheEntryOptions options,
        double jitterPercentage = 0.1)
    {
        if (options.AbsoluteExpirationRelativeToNow.HasValue)
        {
            var baseTtl = options.AbsoluteExpirationRelativeToNow.Value;
            var jitterRange = baseTtl.TotalMilliseconds * jitterPercentage;
            var jitter = _jitter.NextDouble() * jitterRange * 2 - jitterRange;

            options.AbsoluteExpirationRelativeToNow =
                baseTtl + TimeSpan.FromMilliseconds(jitter);
        }

        return options;
    }
}

// Usage
var options = new DistributedCacheEntryOptions
{
    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
}.WithJitter(0.2); // ±20% jitter → TTL between 8-12 minutes

Background Refresh (Stale-While-Revalidate)

Serve the stale value immediately while refreshing in the background. This eliminates latency spikes on cache misses for non-critical data.

csharp
public class StaleWhileRevalidateCache<T> where T : class
{
    private readonly IMemoryCache _cache;

    public async Task<T?> GetOrCreateAsync(
        string key,
        Func<Task<T>> factory,
        TimeSpan freshDuration,
        TimeSpan staleDuration)
    {
        if (_cache.TryGetValue(key, out CacheWrapper<T>? wrapper))
        {
            if (wrapper!.IsStale && !wrapper.IsRefreshing)
            {
                wrapper.IsRefreshing = true;
                // Fire-and-forget background refresh
                _ = Task.Run(async () =>
                {
                    var fresh = await factory();
                    _cache.Set(key, new CacheWrapper<T>(fresh, freshDuration),
                        freshDuration + staleDuration);
                });
            }
            return wrapper.Value;
        }

        var value = await factory();
        _cache.Set(key, new CacheWrapper<T>(value, freshDuration),
            freshDuration + staleDuration);
        return value;
    }
}

public class CacheWrapper<T>
{
    public T Value { get; }
    public DateTime FreshUntil { get; }
    public bool IsStale => DateTime.UtcNow > FreshUntil;
    public bool IsRefreshing { get; set; }

    public CacheWrapper(T value, TimeSpan freshDuration)
    {
        Value = value;
        FreshUntil = DateTime.UtcNow.Add(freshDuration);
    }
}

Monitoring Cache Performance

You can't improve what you don't measure. Cache hit ratio is the single most important metric — if it drops, either your TTLs are too short, your cache is undersized, or your invalidation is too aggressive.

Custom Metrics with IMemoryCache

csharp
public class InstrumentedCacheService
{
    private readonly IMemoryCache _cache;
    private readonly ILogger<InstrumentedCacheService> _logger;
    private static long _hits;
    private static long _misses;

    public async Task<T?> GetAsync<T>(string key)
    {
        if (_cache.TryGetValue(key, out T? value))
        {
            Interlocked.Increment(ref _hits);
            return value;
        }

        Interlocked.Increment(ref _misses);
        return default;
    }

    public double GetHitRatio()
    {
        var total = Interlocked.Read(ref _hits) + Interlocked.Read(ref _misses);
        return total == 0 ? 0 : (double)Interlocked.Read(ref _hits) / total;
    }
}

Health Check for Redis

csharp
public class RedisCacheHealthCheck : IHealthCheck
{
    private readonly IConnectionMultiplexer _redis;

    public RedisCacheHealthCheck(IConnectionMultiplexer redis)
    {
        _redis = redis;
    }

    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken ct = default)
    {
        try
        {
            var db = _redis.GetDatabase();
            var latency = await db.PingAsync();

            var data = new Dictionary<string, object>
            {
                ["latency_ms"] = latency.TotalMilliseconds,
                ["connected_clients"] = _redis.GetServer(
                    _redis.GetEndPoints().First()).Info("clients")
            };

            return latency.TotalMilliseconds < 100
                ? HealthCheckResult.Healthy("Redis is responsive", data)
                : HealthCheckResult.Degraded("Redis latency is high", null, data);
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy("Redis is unreachable", ex);
        }
    }
}

Exposing Metrics Endpoint

csharp
app.MapGet("/metrics/cache", (InstrumentedCacheService cache) =>
{
    return Results.Ok(new
    {
        HitRatio = cache.GetHitRatio(),
        Timestamp = DateTime.UtcNow
    });
});

In production, I pipe these metrics into Prometheus and set alerts when the hit ratio drops below 70%. Any sudden drop usually means something changed in the data access patterns or a deployment introduced a key format change.

Common Caching Mistakes

These are mistakes I've encountered in real production systems:

1. Caching Null Results Without Protection

csharp
// BAD: Missing product returns null, and you query DB every time
var product = await _cache.GetAsync<Product>(key);
if (product is null)
{
    product = await _db.FindAsync(id); // Called on every request
    if (product is not null)
        await _cache.SetAsync(key, product);
}

// GOOD: Cache the absence with a short TTL
var product = await _db.FindAsync(id);
if (product is not null)
{
    await _cache.SetAsync(key, product, TimeSpan.FromMinutes(10));
}
else
{
    // Cache the "not found" to prevent repeated DB hits
    await _cache.SetAsync(key, NullSentinel.Instance, TimeSpan.FromMinutes(1));
}

2. Using Overly Broad Cache Keys

csharp
// BAD: Same key regardless of user, locale, or filters
var key = "products";

// GOOD: Key reflects the actual query parameters
var key = $"products:cat={categoryId}:page={page}:lang={locale}:sort={sortBy}";

3. No Graceful Degradation When Cache Is Down

csharp
// BAD: If Redis is down, the entire API fails
var cached = await _cache.GetStringAsync(key); // Throws!

// GOOD: Catch and fall through to source
public async Task<T?> SafeGetAsync<T>(string key) where T : class
{
    try
    {
        var data = await _cache.GetStringAsync(key);
        return data is not null ? JsonSerializer.Deserialize<T>(data) : null;
    }
    catch (Exception ex)
    {
        _logger.LogWarning(ex, "Cache read failed for {Key}, falling through", key);
        return null; // Gracefully degrade to DB
    }
}

4. Serialization Format Mismatch After Deployment

When you change the shape of a cached object (add/remove properties), old cached entries will fail deserialization. Always version your cache keys or use a format that handles missing properties gracefully.

5. Not Setting Memory Limits

Without SizeLimit on IMemoryCache or maxmemory on Redis, your cache will grow until it causes OOM kills or evicts random keys. Always configure upper bounds.

Conclusion

Great caching architecture is a balance of speed, consistency, and operational clarity. Start with HybridCache if you're on .NET 9 — it handles the two-level caching and stampede prevention out of the box. For older versions, pair IMemoryCache for hot data with IDistributedCache backed by Redis for shared state. Treat cache policy as a product decision, not just a technical tweak: the TTL, invalidation strategy, and monitoring should be designed alongside the feature, not bolted on later.

I can help design cache policies for your high-traffic endpoints — let's talk.

Related Articles

Have a Flutter Project?

I build high-performance Flutter applications for iOS, Android, and web.

Get in Touch