Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20265 min read
Clean Architecture.NETCachingCache-AsideInvalidationPerformance
Share:š•

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
C#
// 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:

C#
// 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.

C#
// 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

C#
// 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

C#
// 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

C#
// 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

C#
// āœ— 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.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.