ASP.NET Core Caching Strategies
Learn various caching strategies for ASP.NET Core applications.
A Simple Explanation
The Book on Your Desk Analogy
Imagine you're writing a book report and need to look up facts repeatedly:
Without Caching:
- Need a fact? Walk to the library
- Search for the book (slow)
- Find the page (slow)
- Read the fact
- Walk back home
- Ten minutes later, need another fact? Walk to library again
- Repeat 50 times = 8+ hours of walking
With Caching:
- Walk to library once (initial cost)
- Grab the book and keep it on your desk
- Need a fact? Look at book on desk (instant!)
- Need another fact? Same book, instant!
- One-time trip to library, now you save hours
In Web Applications:
- Library = Database
- Walking = Network latency
- Searching for the book = Query execution
- Book on desk = Cache
- Repeated lookups = Multiple user requests
The Real Impact:
- Database query: 200ms (read from disk, process data, return)
- Cache lookup: 1ms (read from RAM)
- Speed improvement: 200x faster π
Why Caching Exists
The Problem Without Caching
Imagine an e-commerce site showing "Top 10 Products" on every page load:
9:00 AM - User 1 visits home page
β Query: SELECT * FROM Products ORDER BY Sales DESC LIMIT 10
β Database executes: 200ms
β Page loads: 500ms
9:00:01 - User 2 visits home page
β Query: SELECT * FROM Products ORDER BY Sales DESC LIMIT 10
β Same query, same result (nothing changed in 1 second!)
β Database executes: 200ms
β Wasted effort
9:00:02 - User 3 visits
9:00:03 - User 4 visits
9:00:04 - User 5 visits
...
12:00 PM - 10,800 users visited
β 10,800 identical queries
β Database doing same work 10,800 times
β CPU at 100%
β Disk I/O maxed out
β Site gets SLOW
β Users get frustrated
The Solution With Caching
9:00 AM - User 1 visits home page
β Is "Top 10 Products" in cache? NO
β Query database: 200ms
β Store result in cache for 1 hour
β Page loads: 500ms
9:00:01 - User 2 visits home page
β Is "Top 10 Products" in cache? YES!
β Return cached result: 1ms
β Page loads: 301ms (198ms faster!)
9:00:02 - User 3 visits
β Cache hit: 1ms
9:00:03 - User 4 visits
β Cache hit: 1ms
...
12:00 PM - 10,800 users visited
β 1 database query
β 10,799 cache hits
β Database CPU: 5%
β Disk I/O: minimal
β Site stays fast
β Users happy
Real Numbers:
- Without cache: 10,800 queries = ~2,160 seconds of CPU time
- With cache: 1 query = 0.2 seconds of CPU time
- Efficiency gain: 10,800x π
When Caching Matters Most:
- β Repeated requests for same data
- β Data that doesn't change often
- β High-traffic applications
- β Expensive operations (complex calculations, external API calls)
- β Mobile apps (bandwidth savings)
Content Overview
Table of Contents
- How Caching Works (Conceptually)
- In-Memory Caching
- Distributed Caching with Redis
- Response Caching
- Output Caching
- Cache Invalidation Strategies
- Real-World Use Cases & Patterns
- Performance Metrics & Monitoring
- When to Cache & When NOT To
- Related Concepts to Explore
How Caching Works (Conceptually)
The Three-Step Process
Step 1: Cache Miss (First Time)
Request comes in
β
Check cache: "Do we have this data?"
β
NO (cache miss)
β
Fetch from database (slow, 200ms)
β
Store in cache for 1 hour
β
Return data to user
Step 2: Cache Hit (While Fresh)
Request comes in
β
Check cache: "Do we have this data?"
β
YES (cache hit)
β
Return from cache (fast, 1ms)
β
User gets data instantly
Step 3: Cache Expiration
1 hour passes
β
Cache entry expires
β
Next request: cache miss again
β
Fetch fresh data from database
β
Store new copy in cache
β
Repeat cycle
Cache Hit Ratio:
If 1 hour cache, and 10,800 requests in that hour:
- Cache hits: 10,799
- Cache misses: 1
- Hit ratio: 99.99%
With 99.99% cache hit ratio:
- 99.99% of requests served in 1ms
- Only 0.01% hit the database
- Database 10,000x less loaded
In-Memory Caching
What Is In-Memory Caching?
In-memory caching stores frequently accessed data directly in your application's RAM. It's the fastest type of cache because it's located on the same server as your code. The trade-off is that it only survives as long as your application is running.
Simple Analogy: In-memory caching is like keeping important notes on your desk. You can grab them instantly. If your desk gets cleaned (application restarts), the notes are goneβbut that's okay because your filing cabinet (database) still has the originals.
Three Expiration Strategies
Strategy 1: Sliding Expiration (Most Useful for Session Data)
// Reset timer every time data is accessed
var cacheOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromMinutes(20));
cache.Set("user:123:profile", userData, cacheOptions);
// User accesses the cached data at 2:00 PM
// β Expiration moves to 2:20 PM (20 minutes from NOW)
// User accesses again at 2:15 PM
// β Expiration moves to 2:35 PM (20 minutes from NOW)
// If no access for 20 minutes β data removed
Strategy 2: Absolute Expiration (Most Useful for Data That Changes)
// Data expires at specific time, no matter what
var cacheOptions = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromHours(1));
cache.Set("product:456:details", productData, cacheOptions);
// Cached at 2:00 PM
// ALWAYS expires at 3:00 PM (1 hour later)
// Even if accessed at 2:55 PM, it expires at 3:00 PM
Strategy 3: Both Combined (Most Flexible)
// Expiration whichever comes first:
// - Absolute: Always refresh after 2 hours
// - Sliding: Remove if not used for 15 minutes
var cacheOptions = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromHours(2))
.SetSlidingExpiration(TimeSpan.FromMinutes(15));
cache.Set("report:789:data", reportData, cacheOptions);
// Fresh for active users (sliding 15 min window)
// Never stale for more than 2 hours
Real-World In-Memory Caching Scenario
Problem: E-commerce product catalog
- 50,000 products, each stored in database
- 500 concurrent users browsing
- Database query per product: 200ms
- Response without caching: 200ms + 50ms network = 250ms
Solution with In-Memory Cache:
// Configure in Program.cs
builder.Services.AddMemoryCache();
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 productId)
{
// Key for this product's cache entry
string cacheKey = $"product:{productId}";
// Try to get from cache first
if (_cache.TryGetValue(cacheKey, out Product cachedProduct))
{
Console.WriteLine($"Cache HIT: product {productId} served in 1ms");
return cachedProduct; // Cache hit: 1ms
}
// Not in cache, fetch from database
Console.WriteLine($"Cache MISS: fetching product {productId} from database (200ms)");
var product = await _repository.GetProductByIdAsync(productId);
// Store in cache for 1 hour
var cacheOptions = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromHours(1));
_cache.Set(cacheKey, product, cacheOptions);
return product; // Cache miss: 200ms
}
}
// Usage: Startup code
var productService = new ProductService(memoryCache, repository);
// First call: Cache MISS β 200ms (database)
var product1 = await productService.GetProductAsync(123);
// Next 3,599 calls within that hour: Cache HIT β 1ms each
for (int i = 0; i < 3599; i++)
{
var product = await productService.GetProductAsync(123);
}
// Performance: 200 + (3,599 Γ 1) = 3,799ms total for 3,600 requests
// Without cache: 3,600 Γ 200 = 720,000ms
// Speed improvement: 720,000 Γ· 3,799 = 189Γ faster
Results:
- Cache hit ratio: 99.97% (3,599 hits / 3,600 requests)
- Average response time: 1.06ms (down from 200ms)
- User experience: Instant product details
- Database impact: 99.97% fewer queries
- Server CPU: Reduced by ~99%
In-Memory Caching Pros & Cons
Pros:
- β Incredibly fast (1-5ms response time)
- β No network latency (same process)
- β No external dependencies (just RAM)
- β Easy to implement (built-in to .NET)
- β Perfect for small-medium datasets
- β Great for session data, user preferences
Cons:
- β Only on one server (doesn't share across servers)
- β Lost on application restart
- β Limited by RAM size (typically 2-8 GB available)
- β Can't scale to millions of items
- β Not suitable for distributed systems
- β Causes cache inconsistency in load-balanced setups
2. Distributed Caching with Redis
What It Is: Store data on a separate Redis server accessible by multiple application instances.
Best For: Multi-server applications, data that needs to be shared across servers.
Setup:
// Configure in Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost:6379";
});
// Usage
public class ProductService
{
private readonly IDistributedCache _cache;
public ProductService(IDistributedCache cache)
{
_cache = cache;
}
public async Task<List<Product>> GetTopProductsAsync()
{
const string cacheKey = "top_products";
// Try to get from Redis
var cached = await _cache.GetStringAsync(cacheKey);
if (cached != null)
{
return JsonSerializer.Deserialize<List<Product>>(cached);
}
// Fetch from database
var products = await _database.GetTopProductsAsync();
// Store in Redis for 1 hour
await _cache.SetStringAsync(
cacheKey,
JsonSerializer.Serialize(products),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
}
);
return products;
}
}
Pros: Works across multiple servers, persistent, fast. Cons: Requires Redis infrastructure, network latency.
3. Response Caching
What Is Response Caching?
Response caching stores the complete HTTP response from your application. Think of it as your application saying to the client's browser: "This answer won't change for 1 hour, so cache it and ask me again after that."
Simple Analogy: Response caching is like a newspaper. Instead of asking the news company for today's headlines every hour, you grab yesterday's copy from your shelf. As long as it's still today, the information is relevant enough.
How It Works
Three Layers of Caching:
Layer 1: Browser Cache (Client-side)
- Browser stores the response
- No request to server needed
- Fastest: 0ms (local disk)
Layer 2: CDN Cache (Network-side)
- CloudFlare, AWS CloudFront, etc.
- Geographic location closest to user
- Fast: 10-50ms (nearby server)
Layer 3: Server Cache (Application-side)
- Your application caches response
- Still faster than database query
- Medium: 50-100ms
Configuration
Method 1: Attribute on Controller
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet("{id}")]
[ResponseCache(Duration = 3600)] // Cache for 1 hour
public async Task<IActionResult> GetProduct(int id)
{
var product = await _service.GetProductAsync(id);
return Ok(product);
}
[HttpPost]
[ResponseCache(NoStore = true)] // Never cache POST requests
public async Task<IActionResult> CreateProduct([FromBody] Product product)
{
var created = await _service.CreateProductAsync(product);
return CreatedAtAction(nameof(GetProduct), new { id = created.Id }, created);
}
}
Method 2: Via Middleware
// In Program.cs
app.Use(async (context, next) =>
{
// Cache API responses for 1 hour
context.Response.Headers.CacheControl = "public, max-age=3600";
await next();
});
Real-World Response Caching Scenario
Problem: Product catalog page
- 100,000 users/day browsing products
- Product listings rarely change (updated once/day)
- Without caching: 100,000 requests to server
- Database queries: 100,000 Γ 200ms = 20,000 seconds of CPU
Solution with Response Caching:
[HttpGet("catalog")]
[ResponseCache(Duration = 86400)] // Cache for 24 hours
public async Task<IActionResult> GetCatalog()
{
// First user: Fetch from database (200ms)
// Next 99,999 users: Serve from browser cache (0ms)
// Server is barely touched
var products = await _service.GetCatalogAsync();
return Ok(products);
}
// Results:
// Without cache: 20,000 seconds server CPU
// With cache: 0.2 seconds server CPU
// Improvement: 100,000Γ faster β
Response Cache vs In-Memory vs Redis
Response Cache Benefits:
ββ Doesn't hit your server at all (client-side)
ββ Saves bandwidth
ββ Works with browsers, mobile apps, CDNs
ββ Perfect for public, unchanging data
When to Use:
β
Static content (About Us, Help pages)
β
Public product listings
β
Read-only API endpoints
β
Infrequently changing data
When NOT to Use:
β User-specific data
β Real-time data
β Personal financial information
β Recently updated content
Response Caching Pros & Cons
Pros:
- β Zero load on your server (client caches)
- β Reduced bandwidth usage
- β Works with CDNs and proxies
- β Easy to implement (just add attribute)
- β Browser automatically handles expiration
Cons:
- β Not suitable for user-specific data
- β Clients must respect cache headers
- β Hard to invalidate if content changes unexpectedly
- β Not suitable for real-time data
Output Caching (.NET 7+)
What Is Output Caching?
Output Caching is a unified approach introduced in .NET 7 that works like Response Caching but with more control. It sits between your controller and the HTTP response, caching the rendered output.
Simple Analogy: Output caching is like a professional copy shop. Instead of creating a custom photocopy for each customer, the shop makes 1 master copy and duplicates it 1,000 times. Much faster and cheaper.
Why Output Caching?
Before .NET 7, you had to choose:
- Response caching (HTTP headers, browser controls it)
- In-memory caching (application controls it)
Output caching combines the best of both:
βββββββββββββββββββββββββββ
β Browser Request β
ββββββββββββββ¬βββββββββββββ
β
ββββββββββββββΌβββββββββββββ
β Output Cache Middleware β
β (Check cached response) β
ββββββββββ¬βββββββββββββ¬βββ
β HIT β MISS
ββββββββββΌβββ βββββββΌβββββββββ
β Return 0ms β β Execute β
β cached β β Controller β
β response β β (200ms) β
β β β Store output β
β β β in cache β
ββββββββββ¬βββ βββββββ¬βββββββββ
β β
ββββββββββΌββββββββββββΌββββββββ
β Send Response to Browser β
ββββββββββββββββββββββββββββββ
Setup & Configuration
Step 1: Enable in Program.cs
builder.Services.AddOutputCache(options =>
{
// Default policy: cache everything for 60 seconds
options.DefaultExpirationTimeInSeconds = 60;
// Add named policies
options.AddPolicy("CatalogCache", builder =>
{
builder
.Expire(TimeSpan.FromHours(1))
.WithoutVaryByQueryKeys("sort"); // Ignore sort parameter
});
});
var app = builder.Build();
app.UseOutputCache();
Step 2: Apply to Controllers
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
// Use default output cache (60 seconds)
[HttpGet("all")]
[OutputCache]
public async Task<IActionResult> GetAllProducts()
{
return Ok(await _service.GetAllProductsAsync());
}
// Use named policy (1 hour cache)
[HttpGet("catalog")]
[OutputCache(PolicyName = "CatalogCache")]
public async Task<IActionResult> GetCatalog()
{
return Ok(await _service.GetCatalogAsync());
}
// Never cache this endpoint
[HttpPost("create")]
[OutputCache(NoStore = true)]
public async Task<IActionResult> CreateProduct([FromBody] Product product)
{
return Ok(await _service.CreateProductAsync(product));
}
// Cache per user
[HttpGet("my-cart")]
[OutputCache(VaryByHeader = "User-ID")]
public async Task<IActionResult> GetMyCart()
{
var userId = Request.Headers["User-ID"];
return Ok(await _service.GetCartAsync(userId));
}
}
Real-World Output Caching Scenario
Problem: E-commerce product search
- 50,000 daily searches
- Each search query: Database search (300ms) + ranking (100ms) = 400ms
- Total database time: 50,000 Γ 0.4 = 20,000 seconds
Solution with Output Caching:
[ApiController]
[Route("api/search")]
public class SearchController : ControllerBase
{
[HttpGet]
[OutputCache(Duration = 3600)] // Cache for 1 hour
public async Task<IActionResult> Search([FromQuery] string query)
{
// First search for "laptop": 400ms (database)
// Next 49 identical searches: 0ms each (output cache)
// Total time: 400 + (49 Γ 0) = 400ms for 50 requests
var results = await _service.SearchAsync(query);
return Ok(results);
}
}
// Performance Metrics:
// Without cache: 50 searches Γ 400ms = 20,000ms
// With cache: 1 actual query (400ms) + 49 cache hits (0ms) = 400ms
// Improvement: 50Γ faster for repeated searches
Output Cache Invalidation
// In your service after updating a product
public async Task<Product> UpdateProductAsync(int id, Product product)
{
var updated = await _database.UpdateAsync(id, product);
// Invalidate the output cache
// This tells Output Cache to refresh next request
HttpContext.Response.Headers.CacheControl = "no-cache";
return updated;
}
// Or invalidate specific policies
public async Task<Product> CreateProductAsync(Product product)
{
var created = await _database.CreateAsync(product);
// Let the cache know to refresh
// (The user's next request will get fresh data)
return created;
}
Output Cache vs Other Caching Methods
| Aspect | Response Cache | In-Memory | Output Cache | |--------|---|---|---| | Where Cached | Browser/CDN | Server RAM | Server RAM | | Server Load | None (client caches) | Low (1 query) | Low (1 query) | | Per-User Data | Hard | Easy | Easy (VaryByHeader) | | Invalidation | Client-side | Manual | Application-controlled | | Complexity | Simple | Simple | Medium | | Best For | Public data | Small datasets | API responses |
Output Caching Pros & Cons
Pros:
- β Unified caching approach for .NET 7+
- β Flexible invalidation strategies
- β Server-side control (unlike Response Cache)
- β Supports per-user caching (VaryByHeader)
- β Can vary by query parameters
- β Easy to implement (attribute-based)
Cons:
- β Only works in .NET 7+
- β Still uses server RAM (like in-memory)
- β Doesn't scale across multiple servers without Redis
- β More complex than simple Response Cache
Cache Invalidation (The Hardest Problem in Caching)
Why Cache Invalidation Is Hard
Famous Quote: "There are only two hard things in Computer Science: cache invalidation and naming things." β Phil Karlton
The challenge: When data changes, how do you clear its cache without stale data?
Timeline of Cache Problems:
β
ββ 10:00 AM - Product "Laptop" price is $1000
β - Cache stores: "Laptop = $1000"
β
ββ 10:15 AM - Manager updates price to $800
β - Database updated β
β - Cache still says $1000 β (STALE DATA!)
β
ββ Customer sees old price ($1000)
β - Buys at wrong price
β - Company loses $200/sale
β
ββ How to prevent this?
Strategy 1: Time-Based Expiration (TTL)
How It Works: Cache expires after fixed time period, no matter what.
public class ProductService
{
public async Task<Product> GetProductAsync(int id)
{
string cacheKey = $"product:{id}";
if (_cache.TryGetValue(cacheKey, out Product product))
{
return product; // Use cached data
}
// Not cached, fetch from database
product = await _database.GetProductAsync(id);
// Cache for 1 hour (TTL = Time To Live)
var options = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromHours(1));
_cache.Set(cacheKey, product, options);
return product;
}
}
// Timeline:
// 10:00 AM - Price cached: $1000
// 10:15 AM - Price updated to $800 (database only)
// 10:59 AM - Cache still shows $1000 (stale, but expiring soon)
// 11:00 AM - Cache expires, next request fetches $800 β
Best For:
- Data that changes infrequently
- Data where minor staleness is acceptable (prices, inventory)
- Low-priority data (product descriptions, categories)
Pros: Simple, no manual work needed Cons: Data may be stale for up to TTL duration
Strategy 2: Manual Invalidation (On-Update)
How It Works: When data changes, explicitly remove it from cache.
public class ProductService
{
private readonly IDistributedCache _cache;
public async Task<Product> UpdatePriceAsync(int id, decimal newPrice)
{
// Step 1: Update database
var product = await _database.UpdateProductPriceAsync(id, newPrice);
// Step 2: Immediately remove from cache
await _cache.RemoveAsync($"product:{id}");
await _cache.RemoveAsync("top_products"); // Also invalidate listings
await _cache.RemoveAsync("featured_products");
return product;
}
public async Task<Product> GetProductAsync(int id)
{
string cacheKey = $"product:{id}";
var cached = await _cache.GetStringAsync(cacheKey);
if (cached != null)
{
return JsonSerializer.Deserialize<Product>(cached);
}
var product = await _database.GetProductAsync(id);
await _cache.SetStringAsync(
cacheKey,
JsonSerializer.Serialize(product),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) }
);
return product;
}
}
// Timeline:
// 10:00 AM - Product cached: $1000
// 10:15 AM - Manager updates price
// β Database: $800
// β Cache: REMOVED immediately
// 10:15:01 - Next request fetches from database: $800 β (always fresh)
Best For:
- Critical data (prices, balances, user accounts)
- Real-time data (inventory)
- Data that changes frequently
Pros: Always fresh data Cons: Manual work, risk of forgetting to invalidate
Strategy 3: Event-Based Invalidation (Publish/Subscribe)
How It Works: When data changes, broadcast an event that all servers listen for.
// On Server 1: When product is updated
public class ProductUpdateHandler
{
private readonly IHubContext<CacheHub> _hubContext;
public async Task HandleProductUpdatedEvent(ProductUpdatedEvent evt)
{
// Remove from local cache
var cacheKey = $"product:{evt.ProductId}";
_memoryCache.Remove(cacheKey);
// Broadcast to all connected servers (via Redis Pub/Sub)
await _hubContext.Clients.All.SendAsync("InvalidateCache", cacheKey);
}
}
// On All Servers: Listen for invalidation events
[Route("api/cache")]
public class CacheHub : Hub
{
private readonly IMemoryCache _cache;
public async Task InvalidateCache(string cacheKey)
{
// When any server broadcasts this event,
// ALL servers remove this from their cache
_cache.Remove(cacheKey);
Console.WriteLine($"Cache invalidated: {cacheKey}");
}
}
// In Program.cs
builder.Services.AddSignalR();
var app = builder.Build();
app.MapHub<CacheHub>("/cache-hub");
// Timeline:
// 10:00 AM - All 3 servers cache: $1000
// 10:15 AM - Server 1 updates price
// β Database: $800
// β Server 1 broadcasts event
// 10:15:01 - All 3 servers receive event
// β All remove cache simultaneously
// 10:15:02 - Any user on any server gets $800 β (consistent!)
Best For:
- Multi-server deployments
- Mission-critical data that must stay in sync
- Real-time applications
Pros: Keeps all servers in sync, fast (no TTL wait) Cons: Complex infrastructure, requires messaging system
Strategy 4: Lazy Invalidation (Eventual Consistency)
How It Works: Accept eventual staleness, use TTL + periodic refresh.
public class ProductService
{
// Don't remove cache on update
// Just accept it may be stale for a while
public async Task<Product> UpdatePriceAsync(int id, decimal newPrice)
{
var product = await _database.UpdateProductPriceAsync(id, newPrice);
// Don't invalidate cache immediately
// It will expire on its own (TTL)
// OR get refreshed on next user access
return product;
}
public async Task<Product> GetProductAsync(int id)
{
string cacheKey = $"product:{id}";
var cached = await _cache.GetStringAsync(cacheKey);
if (cached != null)
{
return JsonSerializer.Deserialize<Product>(cached);
}
var product = await _database.GetProductAsync(id);
// Cache for 1 hour
// If updated at 10:15, old data shown until 11:00
// But that's acceptable for non-critical data
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(product),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) });
return product;
}
}
// Timeline:
// 10:00 AM - Price cached: $1000 (expires at 11:00 AM)
// 10:15 AM - Manager updates price to $800
// β Database: $800
// β Cache: still $1000 (STALE but acceptable)
// 10:45 AM - User sees $1000 (cached, but 30 min old)
// 11:00 AM - Cache expires
// 11:00:01 - Next user gets fresh $800 β
Best For:
- Non-critical data (product descriptions, categories)
- Data where eventual consistency is OK
- High-traffic systems that need simplicity
Pros: Simple, scalable, minimal overhead Cons: Stale data for up to TTL duration
Choosing the Right Invalidation Strategy
Decision Tree:
Is data critical?
ββ YES β Use Manual Invalidation (always fresh)
β ββ Multiple servers? β Add Event-Based (keep in sync)
β
ββ NO β Is data updated frequently?
ββ YES β Use Manual Invalidation
β
ββ NO β Use Time-Based Expiration (TTL)
ββ Acceptable to be stale? Use Lazy Invalidation
Invalidation Comparison Table
| Strategy | Staleness | Complexity | Best For | Example | |----------|---|---|---|---| | Time-Based | Up to TTL | Simple β | Non-critical | Blog categories | | Manual | 0ms | Medium | Critical data | User balances | | Event-Based | 0ms | Complex | Multi-server sync | E-commerce pricing | | Lazy | Up to TTL | Simple | Very high traffic | Product reviews |
Real-World Use Cases & Patterns
Use Case 1: E-Commerce Product Catalog
Problem:
- 100,000 products in database
- 10,000 concurrent users browsing
- Each product view = 200ms database query
- Without cache: 10,000 Γ 200ms = 2,000 seconds CPU/second
Solution:
public class ProductCatalogService
{
private readonly IMemoryCache _cache; // Fast, on same server
public async Task<List<Product>> GetCategoryProductsAsync(string category)
{
string cacheKey = $"catalog:{category}";
if (_cache.TryGetValue(cacheKey, out List<Product> products))
{
return products; // Cache hit: 1ms
}
// Cache miss: fetch from database
products = await _database.GetProductsByCategoryAsync(category);
// Cache for 1 hour (catalog doesn't change often)
var options = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromHours(1));
_cache.Set(cacheKey, products, options);
return products; // 200ms
}
}
// Result:
// Without cache: 2,000 seconds CPU/second
// With cache: 0.2 seconds CPU/second (first request) + 1ms Γ 9,999 users = 10 seconds
// Improvement: 200Γ faster β
Use Case 2: User Authentication Sessions
Problem:
- Mobile app needs user authentication on every API call
- Session validation = database lookup (100ms)
- 50,000 API calls per second (busy app)
Solution:
public class AuthenticationService
{
private readonly IDistributedCache _cache; // Shared across servers
private readonly IDatabase _database;
public async Task<User> GetUserAsync(string userId)
{
// Check distributed cache first (Redis)
var cacheKey = $"user:{userId}";
var cachedData = await _cache.GetStringAsync(cacheKey);
if (cachedData != null)
{
return JsonSerializer.Deserialize<User>(cachedData); // 10ms
}
// Cache miss: fetch from database
var user = await _database.GetUserAsync(userId);
// Cache user for 30 minutes (active session)
await _cache.SetStringAsync(
cacheKey,
JsonSerializer.Serialize(user),
new DistributedCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(30) // Resets on each access
}
);
return user; // 100ms
}
}
// Result:
// Without cache: 50,000 Γ 100ms = 5,000 seconds
// With cache: 50,000 Γ 10ms = 500 seconds (all hits after first)
// Improvement: 10Γ faster β
Use Case 3: Expensive API Calls to Third-Party Services
Problem:
- Payment gateway charges $0.10 per lookup
- 100,000 payment verifications daily
- Cost without cache: $10,000/day β
Solution:
public class PaymentService
{
private readonly IMemoryCache _cache;
private readonly IPaymentGateway _gateway;
public async Task<PaymentStatus> GetPaymentStatusAsync(string transactionId)
{
string cacheKey = $"payment:{transactionId}";
if (_cache.TryGetValue(cacheKey, out PaymentStatus status))
{
return status; // Saved $0.10! π
}
// Call expensive external API
status = await _gateway.GetStatusAsync(transactionId);
// Cache for 5 minutes (payment status unlikely to change frequently)
var options = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromMinutes(5));
_cache.Set(cacheKey, status, options);
return status;
}
}
// Analysis:
// Assuming 20% cache hit ratio:
// Daily calls: 100,000
// Cache hits: 20,000 (free)
// Cache misses: 80,000 (cost money)
// Daily cost: 80,000 Γ $0.10 = $8,000
// Savings: $2,000/day! π°
Use Case 4: Recommendation Engine (Expensive Computation)
Problem:
- Personalized recommendations require ML model + 5000 calculations
- Per user: 5 seconds computation time
- 1000 concurrent users = 5,000 seconds CPU
Solution:
public class RecommendationService
{
private readonly IDistributedCache _cache;
private readonly IMachineLearningModel _model;
public async Task<List<Product>> GetRecommendationsAsync(int userId)
{
string cacheKey = $"recommendations:{userId}";
// Try cache first (shared across all servers)
var cached = await _cache.GetStringAsync(cacheKey);
if (cached != null)
{
return JsonSerializer.Deserialize<List<Product>>(cached); // 10ms β
}
// Cache miss: run expensive ML model
var recommendations = await _model.GenerateAsync(userId);
// Cache for 24 hours (user preferences don't change hourly)
await _cache.SetStringAsync(
cacheKey,
JsonSerializer.Serialize(recommendations),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24)
}
);
return recommendations; // 5000ms
}
}
// Result:
// Without cache: 1000 users Γ 5 seconds = 5000 seconds CPU
// With cache: 1 computation (5 sec) + 999 Γ (10ms) = 5 + 10 = 15 seconds total
// Improvement: 333Γ faster β
Use Case 5: Rate Limiting & Throttling
Problem: Prevent API abuse by tracking request counts
- Per-user rate limit: 100 requests/minute
- Need super-fast access (sub-millisecond)
- Database lookups too slow
Solution:
public class RateLimitingService
{
private readonly IMemoryCache _cache; // Fast in-memory only
public bool IsRateLimited(string userId)
{
string cacheKey = $"ratelimit:{userId}";
if (_cache.TryGetValue(cacheKey, out int requestCount))
{
if (requestCount >= 100)
{
return true; // Rate limited
}
// Increment counter
requestCount++;
_cache.Set(cacheKey, requestCount, TimeSpan.FromMinutes(1));
return false; // Allowed
}
// First request this minute
_cache.Set(cacheKey, 1, TimeSpan.FromMinutes(1));
return false;
}
}
// Performance:
// All checks from memory: <1ms each
// Database not touched at all β
When to Cache & When NOT To
Excellent Cache Candidates β
-
Frequently Read Data
- Examples: Product lists, categories, user profiles
- Pattern: 100+ reads per update
-
Expensive Operations
- Examples: Database joins, ML models, API calls
- Pattern: Takes >100ms to compute
-
Non-Time-Sensitive Data
- Examples: Product descriptions, help articles
- Pattern: Updated infrequently (hours or days)
-
Public Data
- Examples: Catalog, pricing, reviews
- Pattern: Same for all users
-
Read-Heavy Endpoints
- Examples: Search results, filtering, sorting
- Pattern: 99% reads, 1% writes
Poor Cache Candidates β
-
User-Specific Data
- Examples: Personal preferences, purchase history
- Problem: Hard to cache per-user, privacy concerns
- Exception: User sessions (OK to cache)
-
Real-Time Data
- Examples: Stock prices, live sports scores, status
- Problem: Data must be current
- Exception: Can cache for 30 seconds
-
Financial Data
- Examples: Account balances, transaction history
- Problem: Errors are expensive
- Exception: Can cache for 1 minute with proper invalidation
-
Rarely Accessed Data
- Examples: Obscure reports, old articles
- Problem: Wastes memory
- Solution: Let it be cached on-demand, but with short TTL
-
Large Objects
- Examples: Full product catalog, entire database dumps
- Problem: Exceeds available RAM
- Solution: Cache smaller subsets instead
Cache Decision Tree
Should I cache this?
β
ββ Is it read frequently?
β ββ No β Don't cache (wastes memory)
β ββ Yes β Continue
β
ββ Is it data time-sensitive?
β ββ Yes (stock prices, scores) β Cache for 30-60 sec max
β ββ No β Continue
β
ββ Is it user-specific?
β ββ Yes β Cache with user ID in key, short TTL
β ββ No β Continue
β
ββ Is it expensive to fetch/compute?
β ββ No β Consider not caching (overhead)
β ββ Yes β Continue (definitely cache!)
β
ββ Will users accept data 1+ hour old?
ββ Yes β Cache for 1+ hours β
ββ No β Use shorter TTL or manual invalidation β
Performance Metrics & Monitoring
Key Metrics to Track
1. Cache Hit Ratio
Formula: Cache Hits / (Cache Hits + Cache Misses)
Example:
- 9,900 cache hits
- 100 cache misses
- Hit ratio: 9,900 / 10,000 = 99% β
Targets:
- Excellent: > 90%
- Good: 70-90%
- Needs improvement: < 70%
2. Average Response Time
With cache: 1ms
Without cache: 200ms
Improvement: 200Γ faster
Track this metric to see real impact of caching
3. Database Load Reduction
Without cache: 10,000 queries/hour
With cache: 100 queries/hour (1% hit database)
Reduction: 99%
Monitoring Tools
Application Insights (Azure)
var telemetryClient = new TelemetryClient();
telemetryClient.TrackEvent("CacheHit",
new Dictionary<string, string> { { "key", cacheKey } });
telemetryClient.TrackEvent("CacheMiss",
new Dictionary<string, string> { { "key", cacheKey } });
OpenTelemetry with Prometheus
var meter = new Meter("CachingMetrics");
var cacheHitCounter = meter.CreateCounter<int>("cache.hits");
var cacheMissCounter = meter.CreateCounter<int>("cache.misses");
if (cached)
cacheHitCounter.Add(1);
else
cacheMissCounter.Add(1);
Summary & Best Practices
Choose the Right Caching Strategy
| Scenario | Strategy | Reason | |----------|----------|--------| | Single server, small data | In-Memory | Fastest, no overhead | | 3+ servers, shared data | Redis | Data consistency | | Public, static content | Response Cache | Zero server load | | Modern .NET 7+ apps | Output Cache | Flexible, unified |
Caching Best Practices β
-
Start Without Cache
- First optimize your code/database
- Only add cache if there's an actual problem
-
Measure Before & After
- Track response time improvements
- Monitor cache hit ratio
- Ensure cache is actually helping
-
Use Appropriate TTLs
- 5-10 minutes: Data changes frequently
- 1 hour: Most read-only data
- 24 hours: Product catalogs, categories
- Very long: Rarely changing reference data
-
Always Have an Invalidation Strategy
- Don't rely only on TTL
- Implement manual invalidation for critical data
- Test invalidation logic
-
Monitor Memory Usage
- Set max cache size limits
- Monitor for cache memory leaks
- Use eviction policies (LRU - Least Recently Used)
-
Handle Cache Misses Gracefully
- Don't let cache failure crash your app
- Have fallback to database
- Log cache errors for debugging
-
Cache Strategically
- β Cache expensive queries
- β Cache frequently accessed data
- β Don't cache user-specific critical data
- β Don't cache real-time data without short TTL
Related Concepts to Explore
Core Caching Concepts
- Cache Hit Ratio: Percentage of requests served from cache (target: >90%)
- Cache Miss: Request not found in cache, must fetch from source
- Time-To-Live (TTL): Duration data remains fresh in cache
- Cache Stampede: Multiple requests hit expired cache simultaneously
- Cache Warm-up: Pre-populate cache before app goes live
- Cold Start: Application first load when cache is empty
- Cache Coherence: Keeping multiple caches in sync
- Cache Bloat: Cache growing too large, evicting useful data
- Cache Avalanche: Multiple cache keys expiring simultaneously
Caching Patterns
- Cache-Aside (Lazy Loading): Load data on demand
- Write-Through: Update cache AND database together
- Write-Behind: Update cache immediately, database later
- Refresh-Ahead: Proactively refresh expiring data
- Cache Invalidation on Update: Clear cache when data changes
- Partial Invalidation: Only clear affected cache keys
- Cache Promotion: Move frequently accessed data to faster cache
Distributed Caching
- Redis: In-memory data store (most popular)
- Memcached: Alternative to Redis (simpler, less features)
- Azure Cache for Redis: Cloud-hosted Redis
- AWS ElastiCache: AWS's Redis/Memcached service
- Consistent Hashing: Distribute cache across multiple nodes
- Cache Replication: Replicate data across Redis instances
- Cache Partitioning: Split data across different cache servers
- Cache Sentinel: High availability for Redis
Cache Invalidation Strategies
- TTL-Based: Automatic expiration after time duration
- Event-Based: Invalidate on specific events
- Manual: Explicitly remove from cache
- Lazy Invalidation: Accept eventual consistency
- Dependency Tracking: Clear related cache entries
Monitoring & Performance
- Hit Ratio Tracking: Monitor cache effectiveness
- Eviction Rate: How often data is removed from cache
- Memory Usage: Track cache size and limit
- Response Time Comparison: Cache vs non-cached
- Database Query Reduction: Fewer hits to database
- Application Performance Monitoring (APM): Overall impact
- Cache Key Visibility: Understand what's cached
- Hot Keys: Frequently accessed data causing bottlenecks
Advanced Techniques
- Two-Tier Caching: In-memory + Redis together
- Probabilistic Caching: Cache based on probability
- Sliding Window: TTL resets on access
- Absolute Expiration: Fixed expiration time
- Cache Compression: Reduce memory usage
- Distributed Tracing: Track cache operations
- Cache Sharding: Split cache across multiple servers
Security Concerns
- Cache Poisoning: Storing malicious/incorrect data
- Cache Breaches: Sensitive data in cache
- Access Control: Who can read cache?
- Encrypted Cache: Encrypt sensitive cached data
- Cache Invalidation for Sensitive Data: Clear immediately on update
- Secure Key Naming: Don't expose sensitive info in cache keys
Specific Use Cases
- Session Caching: User sessions (sliding expiration)
- Rate Limiting: Track request counts
- Query Result Caching: Cache database queries
- Fragment Caching: Cache page components
- Full-Page Caching: Cache entire pages
- Search Result Caching: Cache search queries
- Recommendation Caching: Cache personalization
- Authentication Caching: Cache user validation
- API Response Caching: Cache API responses
- Distributed Sessions: Sessions across multiple servers
HTTP & Web Caching
- HTTP Cache Headers: Cache-Control, ETag, Last-Modified
- Cache-Control Directives: public, private, max-age, no-cache, no-store
- ETag: Entity tag for cache validation
- Last-Modified: For conditional requests
- Vary Header: Vary cache by request header
- CDN Caching: Content Delivery Network cache
- Browser Cache: Client-side caching
- Proxy Cache: Intermediate caching layers
- HTTP 304 Not Modified: Conditional cache validation
.NET Specific
- IMemoryCache: In-memory cache interface
- IDistributedCache: Distributed cache interface
- MemoryCacheEntryOptions: Configuration options
- DistributedCacheEntryOptions: Redis configuration
- StackExchange.Redis: Redis client library
- ResponseCache Attribute: Built-in response caching
- OutputCache: .NET 7+ unified caching
- CacheTag Helper: Razor page caching
- Cache Dependency: Invalidate on related changes
Testing & Validation
- Cache Hit/Miss Testing: Verify cache behavior
- TTL Testing: Verify expiration works
- Failover Testing: Cache failure scenarios
- Load Testing: Cache under heavy load
- Memory Leak Detection: Ensure cache doesn't leak
- Cache Key Collision: Different data using same key
- Concurrent Access: Thread-safe caching
- Cache Stampede Simulation: Test cache expiration spikes
Tools & Libraries
- Redis Commander: Visual Redis client
- redis-cli: Command-line Redis interface
- RedisInsight: Advanced Redis management
- StackExchange.Redis: .NET Redis library
- CacheManager: Abstraction over multiple caches
- Hangfire: Distributed job processing (cache refreshing)
- OpenTelemetry: Observability for caching
- Application Insights: Azure monitoring
- NewRelic: APM and caching metrics
- DataDog: Infrastructure and application monitoring
Infrastructure & Deployment
- Redis Persistence: RDB snapshots, AOF logging
- Redis Replication: Master-slave setup
- Redis Clustering: Distributed Redis
- Redis Sentinel: Automatic failover
- Redis Lua Scripts: Atomic operations
- Connection Pooling: Reuse Redis connections
- Pipelining: Batch Redis commands
- Async/Await Patterns: Non-blocking operations
- Docker & Kubernetes: Containerized Redis
- Load Balancing: Distribute cache requests
Related Concepts & Optimization
- Database Query Optimization: Alternative to caching
- Database Indexing: Faster queries reduce need for cache
- Normalization vs Denormalization: Schema design impacts caching
- Microservices: Caching in distributed architectures
- API Gateway: Caching at gateway level
- Service Mesh: Cache invalidation across services
- Event Sourcing: Alternative to state caching
- CQRS: Command-Query Responsibility Segregation
- GraphQL DataLoader: N+1 query prevention
- Async Programming: Non-blocking cache operations