Why Caching Transforms Performance
Caching stores frequently accessed data in fast memory so you don't have to fetch it repeatedly from slower sources like databases or external APIs. A well-implemented cache can reduce response times from seconds to milliseconds and dramatically decrease load on your backend systems.
The right caching strategy depends on your application's access patterns, data freshness requirements, and scalability needs. In-memory caching works great for single-server applications, while distributed caching becomes essential when you scale horizontally across multiple instances.
You'll learn how to use IMemoryCache for in-memory caching, configure expiration policies that match your data characteristics, and understand when distributed caching makes sense for your architecture.
Getting Started with IMemoryCache
IMemoryCache provides fast in-memory caching within a single application instance. It's built into ASP.NET Core and integrates seamlessly with dependency injection. The cache automatically manages memory pressure and evicts entries when memory runs low.
You inject IMemoryCache into your services and use simple Get/Set operations to store and retrieve data. The cache stores items as objects, so you need to cast when retrieving them. For type safety, use the generic extension methods.
Here's how to configure and use IMemoryCache in a typical ASP.NET Core application:
using Microsoft.Extensions.Caching.Memory;
var builder = WebApplication.CreateBuilder(args);
// Register memory cache
builder.Services.AddMemoryCache(options =>
{
options.SizeLimit = 1024; // Optional: limit cache size
options.CompactionPercentage = 0.25; // Remove 25% when limit reached
});
builder.Services.AddScoped<IProductService, ProductService>();
var app = builder.Build();
app.MapGet("/products/{id}", async (int id, IProductService service) =>
{
var product = await service.GetProductAsync(id);
return product;
});
app.Run();
public interface IProductService
{
Task<Product> GetProductAsync(int id);
}
public class ProductService : IProductService
{
private readonly IMemoryCache _cache;
private readonly ILogger<ProductService> _logger;
public ProductService(IMemoryCache cache, ILogger<ProductService> logger)
{
_cache = cache;
_logger = logger;
}
public async Task<Product> GetProductAsync(int id)
{
string cacheKey = $"product_{id}";
// Try to get from cache
if (_cache.TryGetValue(cacheKey, out Product cachedProduct))
{
_logger.LogInformation("Cache hit for product {Id}", id);
return cachedProduct;
}
// Cache miss - fetch from database
_logger.LogInformation("Cache miss for product {Id}", id);
var product = await FetchFromDatabaseAsync(id);
// Store in cache with options
var cacheOptions = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
Size = 1 // For size-based eviction
};
_cache.Set(cacheKey, product, cacheOptions);
return product;
}
private async Task<Product> FetchFromDatabaseAsync(int id)
{
await Task.Delay(100); // Simulate database call
return new Product { Id = id, Name = $"Product {id}", Price = 99.99m };
}
}
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
The TryGetValue pattern checks for cache existence and retrieves the value in one operation. This avoids race conditions and provides better performance than checking ContainsKey separately. When the cache misses, you fetch the data and store it with appropriate expiration settings.
Mastering Expiration Policies
Expiration policies determine when cached data becomes stale and gets removed. Absolute expiration removes entries after a fixed time, while sliding expiration extends the lifetime each time you access the entry. Choosing the right policy keeps your cache fresh without unnecessary reloading.
Absolute expiration works well for time-sensitive data like authentication tokens or daily reports. Sliding expiration fits frequently accessed data where recent access indicates future access. You can combine both policies for fine-grained control.
Here's how different expiration strategies work in practice:
using Microsoft.Extensions.Caching.Memory;
public class CachingService
{
private readonly IMemoryCache _cache;
public CachingService(IMemoryCache cache)
{
_cache = cache;
}
// Absolute expiration - expires at exact time
public void CacheWithAbsoluteExpiration(string key, object value)
{
var options = new MemoryCacheEntryOptions
{
// Expires 10 minutes from now, regardless of access
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
};
_cache.Set(key, value, options);
}
// Sliding expiration - extends on each access
public void CacheWithSlidingExpiration(string key, object value)
{
var options = new MemoryCacheEntryOptions
{
// Expires 5 minutes after last access
SlidingExpiration = TimeSpan.FromMinutes(5)
};
_cache.Set(key, value, options);
}
// Combined expiration - slides but has maximum lifetime
public void CacheWithCombinedExpiration(string key, object value)
{
var options = new MemoryCacheEntryOptions
{
// Slides on access, but expires after 30 minutes maximum
SlidingExpiration = TimeSpan.FromMinutes(5),
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
};
_cache.Set(key, value, options);
}
// Priority-based eviction
public void CacheWithPriority(string key, object value, bool isImportant)
{
var options = new MemoryCacheEntryOptions
{
Priority = isImportant ? CacheItemPriority.NeverRemove : CacheItemPriority.Low,
SlidingExpiration = TimeSpan.FromMinutes(10)
};
_cache.Set(key, value, options);
}
// Callback on eviction
public void CacheWithCallback(string key, object value)
{
var options = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5),
PostEvictionCallbacks =
{
new PostEvictionCallbackRegistration
{
EvictionCallback = (k, v, reason, state) =>
{
Console.WriteLine($"Cache entry '{k}' evicted. Reason: {reason}");
}
}
}
};
_cache.Set(key, value, options);
}
// GetOrCreate pattern - atomic cache-aside
public async Task<Product> GetOrCreateProductAsync(int id)
{
return await _cache.GetOrCreateAsync(
$"product_{id}",
async entry =>
{
entry.SlidingExpiration = TimeSpan.FromMinutes(5);
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
// This only runs on cache miss
return await FetchProductFromDatabaseAsync(id);
});
}
private async Task<Product> FetchProductFromDatabaseAsync(int id)
{
await Task.Delay(100);
return new Product { Id = id, Name = $"Product {id}", Price = 99.99m };
}
}
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
The GetOrCreateAsync pattern simplifies cache-aside logic by handling the check-fetch-store cycle atomically. This prevents multiple threads from fetching the same data simultaneously. PostEvictionCallbacks let you log evictions or trigger refresh logic when entries expire.
Scaling with Distributed Caching
Distributed caching stores data in a shared cache server accessible by all application instances. This solves cache consistency problems when you scale horizontally and provides cache persistence across application restarts. Redis is the most popular distributed cache in the .NET ecosystem.
IDistributedCache provides a unified interface for distributed caching. You can swap implementations without changing application code. The API differs from IMemoryCache because it works with byte arrays instead of objects, reflecting the serialization required for network transport.
Here's how to set up and use distributed caching with Redis:
using Microsoft.Extensions.Caching.Distributed;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
// Add Redis distributed cache
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost:6379";
options.InstanceName = "MyApp_";
});
builder.Services.AddScoped<ICatalogService, CatalogService>();
var app = builder.Build();
app.MapGet("/catalog", async (ICatalogService service) =>
{
return await service.GetCatalogAsync();
});
app.Run();
public interface ICatalogService
{
Task<List<Product>> GetCatalogAsync();
}
public class CatalogService : ICatalogService
{
private readonly IDistributedCache _cache;
private readonly ILogger<CatalogService> _logger;
public CatalogService(IDistributedCache cache, ILogger<CatalogService> logger)
{
_cache = cache;
_logger = logger;
}
public async Task<List<Product>> GetCatalogAsync()
{
const string cacheKey = "product_catalog";
// Try to get from distributed cache
var cachedData = await _cache.GetStringAsync(cacheKey);
if (cachedData != null)
{
_logger.LogInformation("Distributed cache hit");
return JsonSerializer.Deserialize<List<Product>>(cachedData);
}
// Cache miss - fetch data
_logger.LogInformation("Distributed cache miss");
var products = await FetchCatalogFromDatabaseAsync();
// Serialize and store in cache
var serialized = JsonSerializer.Serialize(products);
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30),
SlidingExpiration = TimeSpan.FromMinutes(10)
};
await _cache.SetStringAsync(cacheKey, serialized, options);
return products;
}
private async Task<List<Product>> FetchCatalogFromDatabaseAsync()
{
await Task.Delay(200); // Simulate database query
return new List<Product>
{
new Product { Id = 1, Name = "Laptop", Price = 999.99m },
new Product { Id = 2, Name = "Mouse", Price = 29.99m },
new Product { Id = 3, Name = "Keyboard", Price = 79.99m }
};
}
}
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
Distributed caching requires serialization, which adds CPU overhead compared to in-memory caching. Choose serialization formats carefully - JSON works well for most scenarios, but binary formats like MessagePack can be faster for large objects. Consider a two-tier cache with in-memory L1 and distributed L2 for optimal performance.
Handling Cache Invalidation
Cache invalidation is notoriously difficult because you need to ensure stale data doesn't persist after updates. Time-based expiration works for many scenarios, but sometimes you need explicit invalidation when data changes.
For in-memory caches, you can remove entries directly. For distributed caches across multiple servers, you need coordination through message buses or cache invalidation services. Tag-based invalidation lets you remove multiple related entries at once.
Here's a pattern for managing cache invalidation with updates:
using Microsoft.Extensions.Caching.Memory;
public class ProductRepository
{
private readonly IMemoryCache _cache;
private readonly ILogger<ProductRepository> _logger;
public ProductRepository(IMemoryCache cache, ILogger<ProductRepository> logger)
{
_cache = cache;
_logger = logger;
}
public async Task<Product> GetProductAsync(int id)
{
string cacheKey = GetCacheKey(id);
return await _cache.GetOrCreateAsync(cacheKey, async entry =>
{
entry.SlidingExpiration = TimeSpan.FromMinutes(10);
return await FetchFromDatabaseAsync(id);
});
}
public async Task UpdateProductAsync(Product product)
{
// Update database
await SaveToDatabaseAsync(product);
// Invalidate specific cache entry
string cacheKey = GetCacheKey(product.Id);
_cache.Remove(cacheKey);
// Invalidate related caches
_cache.Remove("product_list");
_cache.Remove($"category_{product.CategoryId}_products");
_logger.LogInformation("Invalidated cache for product {Id}", product.Id);
}
public async Task<List<Product>> GetProductsByCategoryAsync(int categoryId)
{
string cacheKey = $"category_{categoryId}_products";
return await _cache.GetOrCreateAsync(cacheKey, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15);
return await FetchByCategoryFromDatabaseAsync(categoryId);
});
}
public void InvalidateAllProducts()
{
// Pattern-based invalidation (requires tracking keys)
var keysToRemove = new List<string>
{
"product_list",
// Add all known product keys
};
foreach (var key in keysToRemove)
{
_cache.Remove(key);
}
}
private string GetCacheKey(int id) => $"product_{id}";
private async Task<Product> FetchFromDatabaseAsync(int id)
{
await Task.Delay(50);
return new Product { Id = id, Name = $"Product {id}", Price = 99.99m, CategoryId = 1 };
}
private async Task SaveToDatabaseAsync(Product product)
{
await Task.Delay(50);
}
private async Task<List<Product>> FetchByCategoryFromDatabaseAsync(int categoryId)
{
await Task.Delay(100);
return new List<Product>
{
new Product { Id = 1, Name = "Product 1", Price = 99.99m, CategoryId = categoryId },
new Product { Id = 2, Name = "Product 2", Price = 149.99m, CategoryId = categoryId }
};
}
}
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public int CategoryId { get; set; }
}
Invalidation gets complex with related data. When you update a product, you might need to invalidate category lists, search results, and detail pages. Consider using cache tags or maintaining a key registry for batch invalidation. For distributed scenarios, message queues can broadcast invalidation events to all servers.
Try It Yourself
This complete example shows a caching layer with multiple strategies and metrics tracking. You'll see how caching reduces load and improves response times.
using Microsoft.Extensions.Caching.Memory;
using System.Diagnostics;
var cache = new MemoryCache(new MemoryCacheOptions());
var service = new DataService(cache);
Console.WriteLine("=== Caching Demo ===\n");
// First access - cache miss
var sw = Stopwatch.StartNew();
var data1 = await service.GetExpensiveDataAsync("user_123");
sw.Stop();
Console.WriteLine($"First call: {sw.ElapsedMilliseconds}ms - {data1}");
// Second access - cache hit
sw.Restart();
var data2 = await service.GetExpensiveDataAsync("user_123");
sw.Stop();
Console.WriteLine($"Second call: {sw.ElapsedMilliseconds}ms - {data2}");
// Different key - cache miss
sw.Restart();
var data3 = await service.GetExpensiveDataAsync("user_456");
sw.Stop();
Console.WriteLine($"Different user: {sw.ElapsedMilliseconds}ms - {data3}");
// Display metrics
Console.WriteLine("\n=== Cache Metrics ===");
service.DisplayMetrics();
class DataService
{
private readonly IMemoryCache _cache;
private int _cacheHits = 0;
private int _cacheMisses = 0;
public DataService(IMemoryCache cache)
{
_cache = cache;
}
public async Task<string> GetExpensiveDataAsync(string key)
{
if (_cache.TryGetValue(key, out string cachedValue))
{
_cacheHits++;
return cachedValue;
}
_cacheMisses++;
// Simulate expensive operation
await Task.Delay(500);
var value = $"Data for {key} at {DateTime.Now:HH:mm:ss}";
var options = new MemoryCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromSeconds(30),
PostEvictionCallbacks =
{
new PostEvictionCallbackRegistration
{
EvictionCallback = (k, v, reason, state) =>
{
Console.WriteLine($"\n[Cache] Evicted '{k}' - Reason: {reason}");
}
}
}
};
_cache.Set(key, value, options);
return value;
}
public void DisplayMetrics()
{
var total = _cacheHits + _cacheMisses;
var hitRate = total > 0 ? (_cacheHits * 100.0 / total) : 0;
Console.WriteLine($"Total Requests: {total}");
Console.WriteLine($"Cache Hits: {_cacheHits}");
Console.WriteLine($"Cache Misses: {_cacheMisses}");
Console.WriteLine($"Hit Rate: {hitRate:F1}%");
}
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.0" />
</ItemGroup>
</Project>
Running the example:
- Create a new folder and save both files
- Run
dotnet restore to install packages
- Run
dotnet run to see caching in action
- Notice the dramatic speed difference between hits and misses
- Modify expiration times to see different behaviors
Expected output:
=== Caching Demo ===
First call: 502ms - Data for user_123 at 14:30:15
Second call: 0ms - Data for user_123 at 14:30:15
Different user: 501ms - Data for user_456 at 14:30:16
=== Cache Metrics ===
Total Requests: 3
Cache Hits: 1
Cache Misses: 2
Hit Rate: 33.3%
Design Trade-offs & Alternatives
Caching introduces complexity and trade-offs you need to consider. Memory consumption increases with cache size, potentially affecting other parts of your application. Stale data becomes a risk if you don't handle invalidation properly. Cache warming on application startup can delay availability.
In-memory caching offers the best performance but doesn't share data between instances and loses everything on restart. Distributed caching provides consistency and persistence but adds network latency and infrastructure complexity. Two-tier caching combines both for optimal results.
Consider alternatives to caching. Sometimes optimizing your database queries, adding indexes, or restructuring data provides better results without cache complexity. CDNs handle static content better than application-level caching. Database query result caching might already solve your problem.
Monitor cache effectiveness before expanding it. Track hit rates, eviction reasons, and memory usage. A cache with low hit rates wastes resources. Focus caching efforts on proven bottlenecks. Start simple with in-memory caching and add distributed caching only when scaling requires it.
Remember that caching is an optimization, not a requirement. Build your application to work correctly without caching first. Add caching strategically where measurements show it provides meaningful benefit. This approach keeps your codebase maintainable while delivering performance improvements where they matter most.