C# Caching Patterns
Why Caching
Caching stores computed or fetched data for quick retrieval, reducing latency and load on downstream systems.
// Without caching - hits database every time
public async Task<Product> GetProductAsync(int id)
{
return await _database.Products.FindAsync(id); // ~50ms per call
}
// With caching - returns cached data when available
public async Task<Product> GetProductAsync(int id)
{
var cacheKey = $"product:{id}";
if (_cache.TryGetValue(cacheKey, out Product cached))
return cached; // ~1ms
var product = await _database.Products.FindAsync(id);
_cache.Set(cacheKey, product, TimeSpan.FromMinutes(5));
return product;
}
Choosing Between Cache Types
Cache Type Decision Guide
Choose your cache type based on deployment topology and consistency requirements. The wrong choice can cause subtle bugs in production.
IMemoryCache (In-Process)
- Fast: no serialization, no network
- Each instance has its own cache
- Use for: single instance, or latency-critical read-heavy data
- Trade-off: no cache consistency across instances
IDistributedCache
- Shared across instances via external store (Redis, SQL)
- Survives application restarts
- Use for: multi-instance deployments needing consistency
- Trade-off: serialization overhead, network latency
Hybrid approach: Use both. IMemoryCache as an L1 cache for hot data with very short TTL, backed by IDistributedCache as L2 for shared, longer-lived data. .NET 9βs HybridCache formalizes this pattern.
IMemoryCache (In-Process)
Built-in memory cache for single-instance applications.
Basic Usage
using Microsoft.Extensions.Caching.Memory;
// Registration
services.AddMemoryCache();
// Injection
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}";
// Try to get from cache
if (_cache.TryGetValue(cacheKey, out Product? product))
{
return product;
}
// Load from source
product = await _repository.GetByIdAsync(id);
if (product != null)
{
// Cache with options
var options = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromMinutes(10))
.SetSlidingExpiration(TimeSpan.FromMinutes(2))
.SetPriority(CacheItemPriority.Normal);
_cache.Set(cacheKey, product, options);
}
return product;
}
}
GetOrCreate Pattern
// Synchronous
var product = _cache.GetOrCreate($"product:{id}", entry =>
{
entry.SetAbsoluteExpiration(TimeSpan.FromMinutes(10));
return _repository.GetById(id);
});
// Async
var product = await _cache.GetOrCreateAsync($"product:{id}", async entry =>
{
entry.SetAbsoluteExpiration(TimeSpan.FromMinutes(10));
return await _repository.GetByIdAsync(id);
});
Cache Entry Options
var options = new MemoryCacheEntryOptions
{
// Time-based expiration
AbsoluteExpiration = DateTimeOffset.Now.AddHours(1),
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30),
SlidingExpiration = TimeSpan.FromMinutes(5), // Resets on access
// Eviction priority under memory pressure
Priority = CacheItemPriority.High,
// Size for bounded cache
Size = 1 // Relative size
};
// Callback on eviction
options.RegisterPostEvictionCallback((key, value, reason, state) =>
{
Console.WriteLine($"Cache entry {key} evicted: {reason}");
});
_cache.Set("key", value, options);
Bounded Cache
Limit memory usage by setting cache size.
// Configuration
services.AddMemoryCache(options =>
{
options.SizeLimit = 1000; // Maximum entries (by size sum)
});
// Each entry must specify size
_cache.Set("key", value, new MemoryCacheEntryOptions
{
Size = 1 // Counts as 1 toward limit
});
Cache Invalidation
// Remove specific entry
_cache.Remove($"product:{id}");
// Invalidate with tokens
var cts = new CancellationTokenSource();
var options = new MemoryCacheEntryOptions()
.AddExpirationToken(new CancellationChangeToken(cts.Token));
_cache.Set("key", value, options);
// Later: invalidate all entries with this token
cts.Cancel();
// Linked invalidation
var parentCts = new CancellationTokenSource();
_cache.Set("products:all", allProducts, new MemoryCacheEntryOptions()
.AddExpirationToken(new CancellationChangeToken(parentCts.Token)));
// Child entries depend on parent
_cache.Set($"product:{id}", product, new MemoryCacheEntryOptions()
.AddExpirationToken(new CancellationChangeToken(parentCts.Token)));
// Invalidate all product caches
parentCts.Cancel();
IDistributedCache
Shared cache across multiple application instances.
Interface
public interface IDistributedCache
{
byte[]? Get(string key);
Task<byte[]?> GetAsync(string key, CancellationToken token = default);
void Set(string key, byte[] value, DistributedCacheEntryOptions options);
Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default);
void Refresh(string key);
Task RefreshAsync(string key, CancellationToken token = default);
void Remove(string key);
Task RemoveAsync(string key, CancellationToken token = default);
}
Implementations
// In-memory (for development/testing)
services.AddDistributedMemoryCache();
// SQL Server
services.AddDistributedSqlServerCache(options =>
{
options.ConnectionString = connectionString;
options.SchemaName = "dbo";
options.TableName = "Cache";
});
// Redis
services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost:6379";
options.InstanceName = "myapp:";
});
// NCache
services.AddNCacheDistributedCache(options =>
{
options.CacheName = "myCache";
options.EnableLogs = true;
});
Usage
public class ProductService
{
private readonly IDistributedCache _cache;
private readonly IProductRepository _repository;
private static readonly JsonSerializerOptions JsonOptions = new();
public ProductService(IDistributedCache cache, IProductRepository repository)
{
_cache = cache;
_repository = repository;
}
public async Task<Product?> GetProductAsync(int id, CancellationToken ct = default)
{
var cacheKey = $"product:{id}";
// Try cache
var cached = await _cache.GetStringAsync(cacheKey, ct);
if (cached != null)
{
return JsonSerializer.Deserialize<Product>(cached, JsonOptions);
}
// Load from source
var product = await _repository.GetByIdAsync(id, ct);
if (product != null)
{
var json = JsonSerializer.Serialize(product, JsonOptions);
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
SlidingExpiration = TimeSpan.FromMinutes(2)
};
await _cache.SetStringAsync(cacheKey, json, options, ct);
}
return product;
}
public async Task InvalidateProductAsync(int id, CancellationToken ct = default)
{
await _cache.RemoveAsync($"product:{id}", ct);
}
}
Extension Methods
// Built-in extensions for string values
await _cache.SetStringAsync("key", "value");
var value = await _cache.GetStringAsync("key");
// Custom extensions for objects
public static class DistributedCacheExtensions
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public static async Task SetAsync<T>(
this IDistributedCache cache,
string key,
T value,
DistributedCacheEntryOptions options,
CancellationToken ct = default)
{
var json = JsonSerializer.SerializeToUtf8Bytes(value, JsonOptions);
await cache.SetAsync(key, json, options, ct);
}
public static async Task<T?> GetAsync<T>(
this IDistributedCache cache,
string key,
CancellationToken ct = default)
{
var bytes = await cache.GetAsync(key, ct);
if (bytes == null) return default;
return JsonSerializer.Deserialize<T>(bytes, JsonOptions);
}
public static async Task<T> GetOrSetAsync<T>(
this IDistributedCache cache,
string key,
Func<Task<T>> factory,
DistributedCacheEntryOptions options,
CancellationToken ct = default)
{
var cached = await cache.GetAsync<T>(key, ct);
if (cached != null) return cached;
var value = await factory();
await cache.SetAsync(key, value, options, ct);
return value;
}
}
Redis with StackExchange.Redis
Direct Redis access for advanced scenarios.
using StackExchange.Redis;
// Connection
var redis = ConnectionMultiplexer.Connect("localhost:6379");
var db = redis.GetDatabase();
// Basic operations
await db.StringSetAsync("key", "value", TimeSpan.FromMinutes(10));
var value = await db.StringGetAsync("key");
// Objects (with serialization)
var json = JsonSerializer.Serialize(user);
await db.StringSetAsync($"user:{user.Id}", json);
// Hash operations
await db.HashSetAsync($"user:{id}", new HashEntry[]
{
new("name", user.Name),
new("email", user.Email),
new("age", user.Age)
});
var name = await db.HashGetAsync($"user:{id}", "name");
var allFields = await db.HashGetAllAsync($"user:{id}");
// Sets (unique collections)
await db.SetAddAsync("active-users", userId);
await db.SetRemoveAsync("active-users", userId);
var isActive = await db.SetContainsAsync("active-users", userId);
// Sorted sets (ranked data)
await db.SortedSetAddAsync("leaderboard", playerId, score);
var topPlayers = await db.SortedSetRangeByRankAsync("leaderboard", 0, 9, Order.Descending);
// Lists (queues)
await db.ListRightPushAsync("queue:tasks", taskJson);
var task = await db.ListLeftPopAsync("queue:tasks");
// Pub/Sub
var sub = redis.GetSubscriber();
await sub.SubscribeAsync("notifications", (channel, message) =>
{
Console.WriteLine($"Received: {message}");
});
await sub.PublishAsync("notifications", "Hello!");
Caching Patterns
Cache-Aside (Lazy Loading)
Cache-aside is the most common pattern: load from cache, miss from source, populate cache.
Application manages cache reads and writes.
public async Task<Product?> GetProductAsync(int id)
{
// 1. Check cache
var cached = await _cache.GetAsync<Product>($"product:{id}");
if (cached != null) return cached;
// 2. Load from database
var product = await _repository.GetByIdAsync(id);
// 3. Populate cache
if (product != null)
{
await _cache.SetAsync($"product:{id}", product, DefaultOptions);
}
return product;
}
public async Task UpdateProductAsync(Product product)
{
// 1. Update database
await _repository.UpdateAsync(product);
// 2. Invalidate cache
await _cache.RemoveAsync($"product:{product.Id}");
}
Write-Through
Update cache synchronously with database.
public async Task UpdateProductAsync(Product product)
{
// Update both atomically
await _repository.UpdateAsync(product);
await _cache.SetAsync($"product:{product.Id}", product, DefaultOptions);
}
Write-Behind (Write-Back)
Buffer writes in cache, persist asynchronously.
public class WriteBackCache<T>
{
private readonly IDistributedCache _cache;
private readonly Channel<(string Key, T Value)> _writeQueue;
public WriteBackCache(IDistributedCache cache)
{
_cache = cache;
_writeQueue = Channel.CreateUnbounded<(string, T)>();
_ = ProcessWritesAsync();
}
public async Task SetAsync(string key, T value)
{
// Write to cache immediately
await _cache.SetAsync(key, value, DefaultOptions);
// Queue for database write
await _writeQueue.Writer.WriteAsync((key, value));
}
private async Task ProcessWritesAsync()
{
await foreach (var (key, value) in _writeQueue.Reader.ReadAllAsync())
{
try
{
await PersistToDatabaseAsync(key, value);
}
catch (Exception ex)
{
// Log and potentially retry
}
}
}
}
Stampede Prevention
Prevent multiple cache misses from overloading the database.
public class StampedeProtectedCache
{
private readonly IDistributedCache _cache;
private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();
public async Task<T?> GetOrCreateAsync<T>(
string key,
Func<Task<T>> factory,
DistributedCacheEntryOptions options)
{
// Check cache first
var cached = await _cache.GetAsync<T>(key);
if (cached != null) return cached;
// Get or create lock for this key
var semaphore = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await semaphore.WaitAsync();
try
{
// Double-check after acquiring lock
cached = await _cache.GetAsync<T>(key);
if (cached != null) return cached;
// Only one caller loads data
var value = await factory();
await _cache.SetAsync(key, value, options);
return value;
}
finally
{
semaphore.Release();
}
}
}
Probabilistic Early Expiration
Refresh cache before expiration to prevent misses.
public async Task<T?> GetWithEarlyRefreshAsync<T>(
string key,
Func<Task<T>> factory,
TimeSpan expiration)
{
var entry = await _cache.GetAsync<CacheEntry<T>>(key);
if (entry != null)
{
// Calculate if we should refresh early
var remainingTime = entry.ExpiresAt - DateTimeOffset.UtcNow;
var refreshThreshold = expiration * 0.1; // 10% of TTL
if (remainingTime > refreshThreshold)
{
return entry.Value; // Still fresh
}
// Refresh in background
_ = Task.Run(async () =>
{
var value = await factory();
await SetCacheEntryAsync(key, value, expiration);
});
return entry.Value; // Return stale while refreshing
}
// Cache miss - load synchronously
var newValue = await factory();
await SetCacheEntryAsync(key, newValue, expiration);
return newValue;
}
private record CacheEntry<T>(T Value, DateTimeOffset ExpiresAt);
Response Caching
Cache HTTP responses in ASP.NET Core.
// Register middleware
services.AddResponseCaching();
app.UseResponseCaching();
// Cache for 60 seconds
[ResponseCache(Duration = 60)]
public IActionResult GetProducts()
{
return Ok(_service.GetProducts());
}
// Vary by query parameter
[ResponseCache(Duration = 60, VaryByQueryKeys = new[] { "category" })]
public IActionResult GetProducts(string category)
{
return Ok(_service.GetProducts(category));
}
// No cache
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public IActionResult GetUserData()
{
return Ok(_service.GetUserData());
}
// Cache profiles
services.AddControllersWithViews(options =>
{
options.CacheProfiles.Add("Default", new CacheProfile
{
Duration = 60,
Location = ResponseCacheLocation.Any
});
options.CacheProfiles.Add("Private", new CacheProfile
{
Duration = 300,
Location = ResponseCacheLocation.Client
});
});
[ResponseCache(CacheProfileName = "Default")]
public IActionResult Index() { }
Output Caching (.NET 7+)
Server-side caching of HTTP responses.
// Register
services.AddOutputCache();
app.UseOutputCache();
// Basic caching
app.MapGet("/products", [OutputCache] () => GetProducts());
// With policy
app.MapGet("/products", () => GetProducts())
.CacheOutput(policy => policy
.Expire(TimeSpan.FromMinutes(10))
.SetVaryByQuery("category")
.Tag("products"));
// Named policies
services.AddOutputCache(options =>
{
options.AddPolicy("ProductCache", builder =>
builder.Expire(TimeSpan.FromMinutes(10))
.SetVaryByQuery("category", "page"));
options.AddPolicy("UserCache", builder =>
builder.Expire(TimeSpan.FromMinutes(1))
.SetVaryByHeader("Authorization"));
});
app.MapGet("/products", [OutputCache(PolicyName = "ProductCache")] () => GetProducts());
// Tag-based invalidation
app.MapPost("/products", async (IOutputCacheStore store) =>
{
await CreateProduct();
await store.EvictByTagAsync("products", default);
});
HybridCache (.NET 9)
Combines local and distributed caching with stampede protection.
// Registration
services.AddHybridCache();
// Usage
public class ProductService
{
private readonly HybridCache _cache;
public async Task<Product?> GetProductAsync(int id)
{
return await _cache.GetOrCreateAsync(
$"product:{id}",
async ct => await _repository.GetByIdAsync(id, ct),
new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10),
LocalCacheExpiration = TimeSpan.FromMinutes(1)
});
}
}
Best Practices
Cache Key Design
// Include all relevant parameters
var key = $"user:{userId}:orders:{status}:page:{page}";
// Use consistent naming convention
var key = $"{prefix}:{entityType}:{id}:{variant}";
// Consider key length for distributed cache
// Redis: keep keys under 1KB, ideally < 100 bytes
What to Cache
// Good candidates:
// - Expensive database queries
// - External API responses
// - Computed/aggregated data
// - Configuration data
// Avoid caching:
// - User-specific sensitive data
// - Rapidly changing data
// - Data requiring strong consistency
// - Very large objects (serialize cost > db cost)
Monitoring
public class InstrumentedCache : IDistributedCache
{
private readonly IDistributedCache _inner;
private readonly ILogger _logger;
private static readonly Counter<long> HitCount = /* metrics */;
private static readonly Counter<long> MissCount = /* metrics */;
public async Task<byte[]?> GetAsync(string key, CancellationToken token)
{
var result = await _inner.GetAsync(key, token);
if (result != null)
{
HitCount.Add(1);
_logger.LogDebug("Cache hit: {Key}", key);
}
else
{
MissCount.Add(1);
_logger.LogDebug("Cache miss: {Key}", key);
}
return result;
}
}
Version History
| Feature | Version | Significance |
|---|---|---|
| IMemoryCache | .NET Core 1.0 | In-memory caching |
| IDistributedCache | .NET Core 1.0 | Distributed cache abstraction |
| Output Caching | .NET 7 | Server-side response cache |
| HybridCache | .NET 9 | Combined L1/L2 caching |
Key Takeaways
IMemoryCache for single-instance: Fast, no serialization, but doesnβt scale horizontally.
IDistributedCache for multi-instance: Shared cache via Redis, SQL Server, etc.
Cache-aside is most common: Application manages cache reads and invalidation.
Prevent stampede: Use locks or semaphores to prevent thundering herd on cache miss.
Set appropriate TTL: Balance freshness against cache hit rate.
Invalidate on writes: Update or remove cache entries when source data changes.
Monitor cache effectiveness: Track hit rate, latency, and memory usage.
Found this guide helpful? Share it with your team:
Share on LinkedIn