Cache Strategies ā Cache-Aside, Stampede Protection, and Invalidation
Practical caching strategies for .NET APIs: Cache-Aside pattern, write-through vs write-behind, stampede protection with HybridCache, cache invalidation approaches, and the production bugs that come from getting them wrong.
The Cache-Aside Pattern
Cache-Aside (Lazy Loading) is the most common pattern: the application checks the cache first; if the value is missing, it loads from the database, writes to cache, and returns.
Request arrives
ā
ā¼
Cache hit? ā YES ā return cached value
ā
NO
ā
ā¼
Load from database
ā
ā¼
Write to cache with TTL
ā
ā¼
Return value// Application/Patients/Queries/GetPatient/GetPatientQueryHandler.cs
public async Task<Result<PatientResponse>> Handle(
GetPatientQuery query, CancellationToken ct)
{
var cacheKey = $"patient:{query.PatientId.Value}";
// HybridCache implements Cache-Aside in one call
var cached = await _cache.GetOrCreateAsync(
key: cacheKey,
factory: async token =>
{
var patient = await _patients.GetByIdAsync(query.PatientId, token);
return patient is null ? null : MapToResponse(patient);
},
options: new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10),
LocalCacheExpiration = TimeSpan.FromSeconds(30),
},
tags: [$"patient-{query.PatientId.Value}"],
ct);
return cached is null
? Result.Failure<PatientResponse>(PatientErrors.NotFound)
: Result.Success(cached);
}Write-Through (Synchronous Cache Update on Write)
Write-through updates the cache immediately after a successful write. Ensures cache is always warm ā no cold-start miss after a write:
// After creating a patient, populate the cache immediately
public async Task<Result<PatientId>> Handle(
CreatePatientCommand command, CancellationToken ct)
{
// ... create patient, save to DB ...
await _unitOfWork.SaveChangesAsync(ct);
// Write-through: populate cache so first read is a cache hit
var response = MapToResponse(newPatient);
await _cache.SetAsync(
key: $"patient:{newPatient.Id.Value}",
value: response,
options: new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10),
},
tags: [$"patient-{newPatient.Id.Value}"],
ct);
return Result.Success(newPatient.Id);
}Cache Invalidation ā The Hard Problem
Production issue I've seen: A pharmacist updated a patient's active medications. The cache entry for that patient was valid for 5 more minutes. For those 5 minutes, doctors querying the patient's prescription list saw the old data. A medication was listed as "active" that had just been discontinued. The fix was tag-based invalidation: every write that touches patient data invalidates the patient's cache tag immediately.
// Three invalidation strategies:
// 1. Time-based expiry (simplest, acceptable staleness)
// Good for: reference data (drug formulary, ICD-10 codes)
// Bad for: frequently-written clinical data
options.Expiration = TimeSpan.FromMinutes(10);
// 2. Explicit key removal (point invalidation)
// Good for: simple single-entity invalidation
await _cache.RemoveAsync($"patient:{patientId.Value}", ct);
// 3. Tag-based invalidation (recommended)
// Good for: cascading invalidation (patient tag covers all patient-related queries)
await _cache.RemoveByTagAsync($"patient-{patientId.Value}", ct);Tag Strategy
// Tag naming convention (examples for a clinical system):
"patient-{id}" ā patient profile, prescriptions, drug orders
"patient-list" ā paginated list queries
"formulary" ā drug formulary (global reference data)
"formulary-{code}" ā specific drug entry
"drug-interactions-{code}" ā interaction data for one drug
// When to invalidate which tags:
// Patient name update: RemoveByTagAsync($"patient-{id}")
// Prescription added: RemoveByTagAsync($"patient-{id}")
// Patient deactivated: RemoveByTagAsync($"patient-{id}"), RemoveByTagAsync("patient-list")
// New patient registered: RemoveByTagAsync("patient-list")
// Formulary updated: RemoveByTagAsync("formulary")Stampede Protection in Detail
// Without protection ā the thundering herd problem:
// A popular cache key (drug formulary, read 500 times/second) expires at T=0.
// All 500 concurrent requests see a cache miss and call the database simultaneously.
// Database spike: potentially timeouts, degraded performance, cascade failures.
// With HybridCache's built-in stampede protection:
// All 500 requests call GetOrCreateAsync simultaneously.
// Only 1 request executes the factory.
// 499 requests wait (via SemaphoreSlim) and receive the result from the 1 factory call.
// The database receives exactly 1 query.
// This requires no extra code ā it is built into GetOrCreateAsync.Caching Expensive Aggregations
// A drug interaction check queries multiple data sources ā expensive to compute
public sealed class GetDrugInteractionsQueryHandler
{
private readonly IDrugInteractionService _interactions;
private readonly HybridCache _cache;
public async Task<Result<DrugInteractionReport>> Handle(
GetDrugInteractionsQuery query, CancellationToken ct)
{
// Sort medication codes so {A,B} and {B,A} hit the same cache key
var sortedCodes = query.MedicationCodes
.OrderBy(c => c)
.ToList();
var cacheKey = $"interactions:{string.Join(",", sortedCodes)}";
var report = await _cache.GetOrCreateAsync(
key: cacheKey,
factory: async token =>
{
// This calls an external drug interaction database API ā takes 300ms
return await _interactions.CheckAsync(sortedCodes, token);
},
options: new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromHours(24), // interaction data rarely changes
LocalCacheExpiration = TimeSpan.FromMinutes(5),
},
tags: sortedCodes.Select(c => $"formulary-{c}").ToList(),
ct);
return Result.Success(report);
}
}What NOT to Cache
// ā Do NOT cache real-time clinical data
// INR readings, current vitals, live lab results ā stale data is dangerous
// If a patient's INR was 1.8 at 8am but is now 4.9 at 10am, the doctor must see 4.9
// ā Do NOT cache mutation results
// After creating a patient, do NOT cache the creation result (only read responses)
// Cache the read response after the fact (write-through if you want it pre-populated)
// ā Do NOT cache sensitive data with long TTLs
// Refresh token state, session data, authorization decisions should not live in cache
// for extended periods ā a revoked token cached for 10 minutes is a security hole
// ā DO cache:
// Drug formulary entries (changes daily at most)
// ICD-10 code lookups (changes annually)
// Patient profile (changes rarely, read very frequently)
// Expensive aggregation reports (department-level statistics)PRO TIP ā Cache-Miss Monitoring
Cache hit rate below 80% on frequently-read data is a signal to investigate. Log cache misses (HybridCache fires events you can hook), and alert when the miss rate spikes. A sudden rise in cache misses often indicates an invalidation bug or a new code path bypassing the cache.
Key Takeaway
Cache-Aside is the right default pattern: lazy, simple, and works with any storage. Tag-based invalidation is the right invalidation strategy: you tag data by domain concept, not by individual key, so one write operation invalidates everything related. Stampede protection is not optional at scale ā a cache expiry without it is a self-inflicted database spike waiting to happen. HybridCache gives you all three out of the box.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.