.NET Caching
Introduction
Caching is a technique that stores frequently accessed data in memory for quicker access, thereby improving application performance and reducing the load on resources like databases. In .NET applications, caching becomes especially important when building web applications that need to handle multiple requests efficiently.
This guide will introduce you to caching concepts in .NET, explore different caching mechanisms, and show you how to implement them in your applications.
Why Use Caching?
Before diving into implementation details, let's understand why caching is critical:
- Improved Performance: Reduces response time by serving data from memory instead of fetching it from slower sources
- Reduced Server Load: Decreases the number of database queries or external API calls
- Better Scalability: Helps your application handle more concurrent users
- Lower Costs: Reduces the need for additional infrastructure by utilizing existing resources efficiently
Types of Caching in .NET
.NET provides several built-in caching mechanisms:
- In-Memory Cache: Quick and simple caching within a single server instance
- Distributed Cache: Sharing cache across multiple server instances
- Response Caching: Caching HTTP responses to avoid regenerating the same content
- Output Caching: Caching rendered pages or partial views
- Data Caching: Caching data retrieved from databases or external services
Let's explore each of these mechanisms with practical examples.
In-Memory Cache
In-Memory caching is the simplest form of caching in .NET applications. It stores data in the application's memory space.
Setting Up In-Memory Cache
First, you need to add the required package and services to your application:
// In Program.cs or Startup.cs
using Microsoft.Extensions.Caching.Memory;
// Add this in the ConfigureServices method
builder.Services.AddMemoryCache();
Basic Usage
Here's how to use the in-memory cache in a controller or service:
using Microsoft.Extensions.Caching.Memory;
public class ProductService
{
private readonly IMemoryCache _memoryCache;
private readonly DbContext _dbContext;
public ProductService(IMemoryCache memoryCache, DbContext dbContext)
{
_memoryCache = memoryCache;
_dbContext = dbContext;
}
public async Task<List<Product>> GetFeaturedProductsAsync()
{
// Try to get the data from the cache
if (_memoryCache.TryGetValue("FeaturedProducts", out List<Product> products))
{
return products;
}
// If not in cache, get from database
products = await _dbContext.Products.Where(p => p.IsFeatured).ToListAsync();
// Store in cache for 15 minutes
var cacheEntryOptions = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromMinutes(15));
_memoryCache.Set("FeaturedProducts", products, cacheEntryOptions);
return products;
}
}
Advanced Cache Options
You can configure various cache entry options:
var cacheEntryOptions = new MemoryCacheEntryOptions()
// Expire after 15 minutes
.SetAbsoluteExpiration(TimeSpan.FromMinutes(15))
// OR: Expire if not accessed for 5 minutes
.SetSlidingExpiration(TimeSpan.FromMinutes(5))
// Cache priority
.SetPriority(CacheItemPriority.High)
// Register callbacks
.RegisterPostEvictionCallback((key, value, reason, state) =>
{
Console.WriteLine($"Entry for key {key} was evicted due to {reason}");
});
Distributed Cache
When your application runs on multiple servers (like in cloud environments), you need a cache that can be shared across these instances. This is where distributed caching comes in.
Implementing Redis Cache
Redis is one of the most popular distributed cache solutions. Here's how to set it up in your .NET application:
- First, install the required NuGet package:
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
- Configure the Redis cache in your application:
// In Program.cs or Startup.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost:6379"; // Or your Redis server address
options.InstanceName = "SampleApp_";
});
- Use the distributed cache in your service:
using Microsoft.Extensions.Caching.Distributed;
using System.Text.Json;
public class ProductService
{
private readonly IDistributedCache _distributedCache;
private readonly DbContext _dbContext;
public ProductService(IDistributedCache distributedCache, DbContext dbContext)
{
_distributedCache = distributedCache;
_dbContext = dbContext;
}
public async Task<List<Product>> GetFeaturedProductsAsync()
{
// Try to get the data from the cache
string cacheKey = "FeaturedProducts";
string cachedProducts = await _distributedCache.GetStringAsync(cacheKey);
List<Product> products;
if (cachedProducts != null)
{
// Deserialize the cached JSON back to a list of products
products = JsonSerializer.Deserialize<List<Product>>(cachedProducts);
return products;
}
// If not in cache, get from database
products = await _dbContext.Products.Where(p => p.IsFeatured).ToListAsync();
// Serialize and store in cache for 15 minutes
var cacheEntryOptions = new DistributedCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromMinutes(15));
string serializedProducts = JsonSerializer.Serialize(products);
await _distributedCache.SetStringAsync(cacheKey, serializedProducts, cacheEntryOptions);
return products;
}
}
Other Distributed Cache Providers
.NET supports other distributed cache providers as well:
- SQL Server:
services.AddDistributedSqlServerCache(options =>
{
options.ConnectionString = "YourConnectionString";
options.SchemaName = "dbo";
options.TableName = "CacheTable";
});
- NCache:
services.AddNCacheDistributedCache(options =>
{
options.CacheName = "demoCache";
options.EnableLogs = true;
});
Response Caching
Response caching is ideal for storing HTTP responses. ASP.NET Core provides middleware for caching responses based on HTTP headers.
Setting Up Response Caching
- Add the required package and services:
// In Program.cs or Startup.cs
builder.Services.AddResponseCaching();
// In the middleware pipeline
app.UseResponseCaching();
- Configure caching attributes on your controllers or actions:
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet]
[ResponseCache(Duration = 60)] // Cache for 60 seconds
public async Task<ActionResult<List<Product>>> GetProducts()
{
// Your code to retrieve products
return Ok(products);
}
[HttpGet("{id}")]
[ResponseCache(Duration = 120, VaryByQueryKeys = new[] { "id" })]
public async Task<ActionResult<Product>> GetProduct(int id)
{
// Your code to retrieve a specific product
return Ok(product);
}
}
Customizing Cache Profiles
You can define reusable cache profiles in your application configuration:
// In Program.cs or Startup.cs
services.AddControllers(options =>
{
options.CacheProfiles.Add("Default",
new CacheProfile
{
Duration = 60
});
options.CacheProfiles.Add("LongLived",
new CacheProfile
{
Duration = 300
});
});
Then apply them to your controllers:
[ResponseCache(CacheProfileName = "Default")]
public async Task<ActionResult<List<Product>>> GetProducts()
{
// Method implementation
}
Output Caching
ASP.NET Core 7.0 and later includes a new output caching middleware that provides more control than response caching.
Setting Up Output Caching
// In Program.cs
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("Products", builder => builder
.Tag("product")
.Expire(TimeSpan.FromMinutes(10))
.SetVaryByQuery("category"));
});
// In middleware pipeline
app.UseOutputCache();
Using Output Caching in Controllers
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet]
[OutputCache(PolicyName = "Products")]
public async Task<ActionResult<List<Product>>> GetProducts([FromQuery] string category)
{
// Your implementation here
}
[HttpPost]
public async Task<IActionResult> CreateProduct(ProductDto productDto)
{
// Create product logic
// Invalidate cache when new product is added
HttpContext.InvalidateOutputCache("product");
return Created(...);
}
}
Caching Data with Entity Framework Core
When working with Entity Framework Core, you can implement caching to reduce database queries:
public class CachedProductRepository : IProductRepository
{
private readonly ApplicationDbContext _context;
private readonly IMemoryCache _cache;
public CachedProductRepository(ApplicationDbContext context, IMemoryCache cache)
{
_context = context;
_cache = cache;
}
public async Task<IEnumerable<Product>> GetAllAsync()
{
string cacheKey = "AllProducts";
// Try to get from cache
if (!_cache.TryGetValue(cacheKey, out IEnumerable<Product> products))
{
// Cache miss - fetch from database
products = await _context.Products
.Include(p => p.Category)
.ToListAsync();
// Save to cache for 10 minutes
_cache.Set(cacheKey, products, TimeSpan.FromMinutes(10));
}
return products;
}
public async Task<Product> GetByIdAsync(int id)
{
string cacheKey = $"Product-{id}";
if (!_cache.TryGetValue(cacheKey, out Product product))
{
product = await _context.Products
.Include(p => p.Category)
.FirstOrDefaultAsync(p => p.Id == id);
if (product != null)
{
_cache.Set(cacheKey, product, TimeSpan.FromMinutes(10));
}
}
return product;
}
public async Task AddAsync(Product product)
{
await _context.Products.AddAsync(product);
await _context.SaveChangesAsync();
// Invalidate the cache that contains all products
_cache.Remove("AllProducts");
}
}
Cache Invalidation Strategies
Effective caching requires knowing when to invalidate stale data:
-
Time-Based Invalidation: Set expiration times
csharp_cache.Set(cacheKey, data, TimeSpan.FromMinutes(10));
-
Manual Invalidation: Explicitly remove items when data changes
csharp// When updating a product
public async Task UpdateProductAsync(Product product)
{
_dbContext.Update(product);
await _dbContext.SaveChangesAsync();
// Invalidate cache entries
_cache.Remove($"Product-{product.Id}");
_cache.Remove("AllProducts");
} -
Dependency-Based Invalidation: Link cache entries to dependencies
csharpvar cts = new CancellationTokenSource();
var cacheEntryOptions = new MemoryCacheEntryOptions()
.AddExpirationToken(new CancellationChangeToken(cts.Token));
_cache.Set("MyCachedData", data, cacheEntryOptions);
// When data changes
cts.Cancel(); // This will invalidate the cache entry
Best Practices for Caching
- Cache Duration: Set appropriate cache durations based on data volatility
- Key Design: Create meaningful and consistent cache keys
- Cache Serialization: Ensure efficient serialization for distributed caches
- Monitor Cache Usage: Track cache hit/miss ratios to optimize your strategy
- Consider Memory Usage: Be mindful of memory consumption with large caches
- Fail Gracefully: Handle cache failures without disrupting application function
Real-World Example: Building a Caching Layer
Let's build a complete example of a caching layer for a blog application:
// ICacheService.cs
public interface ICacheService
{
T Get<T>(string key);
void Set<T>(string key, T value, TimeSpan expiration);
void Remove(string key);
bool Exists(string key);
}
// CacheService.cs
public class CacheService : ICacheService
{
private readonly IMemoryCache _memoryCache;
public CacheService(IMemoryCache memoryCache)
{
_memoryCache = memoryCache;
}
public T Get<T>(string key)
{
_memoryCache.TryGetValue(key, out T value);
return value;
}
public void Set<T>(string key, T value, TimeSpan expiration)
{
_memoryCache.Set(key, value, expiration);
}
public void Remove(string key)
{
_memoryCache.Remove(key);
}
public bool Exists(string key)
{
return _memoryCache.TryGetValue(key, out _);
}
}
// BlogService.cs
public class BlogService
{
private readonly IBlogRepository _repository;
private readonly ICacheService _cacheService;
public BlogService(IBlogRepository repository, ICacheService cacheService)
{
_repository = repository;
_cacheService = cacheService;
}
public async Task<IEnumerable<BlogPost>> GetRecentPostsAsync()
{
string cacheKey = "RecentPosts";
// Try to get from cache first
var cachedPosts = _cacheService.Get<IEnumerable<BlogPost>>(cacheKey);
if (cachedPosts != null)
{
return cachedPosts;
}
// If not in cache, get from database
var posts = await _repository.GetRecentPostsAsync();
// Store in cache for 5 minutes
_cacheService.Set(cacheKey, posts, TimeSpan.FromMinutes(5));
return posts;
}
public async Task<BlogPost> GetPostByIdAsync(int id)
{
string cacheKey = $"BlogPost-{id}";
// Try to get from cache
var cachedPost = _cacheService.Get<BlogPost>(cacheKey);
if (cachedPost != null)
{
return cachedPost;
}
// If not in cache, get from database
var post = await _repository.GetByIdAsync(id);
// Store in cache for 10 minutes if post exists
if (post != null)
{
_cacheService.Set(cacheKey, post, TimeSpan.FromMinutes(10));
}
return post;
}
public async Task AddCommentAsync(int postId, Comment comment)
{
await _repository.AddCommentAsync(postId, comment);
// Invalidate the specific post cache since its comments have changed
_cacheService.Remove($"BlogPost-{postId}");
}
public async Task UpdatePostAsync(BlogPost post)
{
await _repository.UpdateAsync(post);
// Invalidate related cache entries
_cacheService.Remove($"BlogPost-{post.Id}");
_cacheService.Remove("RecentPosts");
}
}
// BlogController.cs
[ApiController]
[Route("api/[controller]")]
public class BlogController : ControllerBase
{
private readonly BlogService _blogService;
public BlogController(BlogService blogService)
{
_blogService = blogService;
}
[HttpGet]
[ResponseCache(Duration = 60)]
public async Task<ActionResult<IEnumerable<BlogPost>>> GetRecentPosts()
{
var posts = await _blogService.GetRecentPostsAsync();
return Ok(posts);
}
[HttpGet("{id}")]
[ResponseCache(Duration = 120, VaryByQueryKeys = new[] { "id" })]
public async Task<ActionResult<BlogPost>> GetPost(int id)
{
var post = await _blogService.GetPostByIdAsync(id);
if (post == null)
{
return NotFound();
}
return Ok(post);
}
[HttpPost("{id}/comments")]
public async Task<IActionResult> AddComment(int id, [FromBody] Comment comment)
{
await _blogService.AddCommentAsync(id, comment);
return NoContent();
}
}
Summary
Caching is an essential technique for improving the performance and scalability of your .NET applications. We've covered:
- In-Memory Caching: Quick and simple caching within a single server
- Distributed Caching: Sharing cache across multiple servers using Redis and other providers
- Response Caching: HTTP response caching using built-in middleware
- Output Caching: More advanced HTTP response caching with finer control
- Data Caching: Strategies for caching data retrieved from databases
- Cache Invalidation: Different approaches to keeping cache data fresh
- Best Practices: Guidelines for implementing caching effectively
Properly implemented caching can dramatically improve your application's performance, reduce database load, and enhance the user experience. However, it's important to carefully consider what to cache, for how long, and when to invalidate the cache to avoid serving stale data.
Additional Resources
- Microsoft Docs: Memory Caching in ASP.NET Core
- Microsoft Docs: Distributed Caching in ASP.NET Core
- Microsoft Docs: Response Caching Middleware
- GitHub: Output Caching Sample
- Stack Exchange Redis Documentation
Exercises
- Implement an in-memory cache for a product catalog in a sample e-commerce application
- Set up Redis distributed caching for a web application and test it across multiple instances
- Create a caching layer with automatic invalidation for an entity that frequently changes
- Compare the performance of cached vs. uncached database queries using benchmark tools
- Implement a sliding cache expiration strategy for user session data
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)