Problem Context

A request that hits your database every time scales linearly with your user count and dies the moment a celebrity tweets about you. A cache turns that "every request" into "one request per cache miss" β€” the difference between a 10x and a 1000x system. But caches are also the source of the most embarrassing production bugs in the industry: stale data shown to the wrong user, thundering herds taking down the database the cache was supposed to protect, and the "one of the two hard problems" clichΓ© about cache invalidation existing because it's genuinely hard.

This is the working caching playbook in 2026: where to put the cache, which strategy to use, what TTLs mean, and how to avoid the four failure modes that show up in every postmortem.

πŸ€” Sound familiar?
  • You added Redis "to make it faster" without measuring what to cache
  • Your cache and your DB disagree and nobody knows who's right
  • Your Redis went down and the DB followed five minutes later
  • You can't explain the difference between cache-aside, write-through, and write-behind

This is the caching mental model that survives a real incident β€” placement, strategies, invalidation, and the four classes of cache failure.

Concept Explanation

Caches live at five layers, and the cheapest cache is the one closest to the user:

  • Browser / CDN edge β€” milliseconds. Best for immutable assets and short-TTL HTML.
  • Reverse proxy (nginx, Front Door) β€” milliseconds. Cache full HTTP responses keyed by URL + Vary headers.
  • Application memory(in-process LRU, MemoryCache) β€” sub-microsecond. No network hop, but doesn't survive restarts and isn't shared.
  • Distributed cache (Redis, Memcached) β€” sub-millisecond. Shared across instances, durable across restarts.
  • Database query/result cacheβ€” last resort. The DB already has buffer pools; reach for this only when you can't move logic.

flowchart LR
    U["User"] --> CDN["CDN / Edge<br/>cache: assets, HTML"]
    CDN --> LB["Reverse Proxy<br/>cache: GET /api/popular"]
    LB --> APP["App<br/>L1: in-process LRU"]
    APP --> R["Redis<br/>L2: shared cache"]
    R --> DB["Database<br/>buffer pool"]

    APP -.->|miss| R
    R -.->|miss| DB
    DB -.->|fill| R
    R -.->|fill| APP

    style R fill:#dc2626,color:#fff,stroke:#b91c1c
    style DB fill:#0078D4,color:#fff,stroke:#005a9e

Implementation

Step 1: Pick a strategy β€” cache-aside is the default

// Cache-aside (lazy-loading): app owns the cache; DB is the source of truth.
public async Task<Product?> GetProductAsync(int id)
{
    var key = $"product:{id}";
    var cached = await cache.GetStringAsync(key);
    if (cached is not null)
        return JsonSerializer.Deserialize<Product>(cached);

    var product = await db.Products.FindAsync(id);
    if (product is not null)
    {
        await cache.SetStringAsync(key, JsonSerializer.Serialize(product),
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });
    }
    return product;
}

The other strategies in one paragraph each: Write-through writes to cache + DB synchronously β€” strong consistency, slower writes. Write-behind writes to cache, async-flushes to DB β€” fastest writes, risk of data loss on crash.Read-through hides the cache from app code behind a loader β€” clean abstraction but you give up control over what gets cached.

Step 2: Invalidation β€” explicit, on the write path

public async Task UpdatePriceAsync(int productId, decimal newPrice)
{
    await db.Products.Where(p => p.Id == productId)
        .ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, newPrice));

    // Invalidate every key that depended on this product
    await cache.RemoveAsync($"product:{productId}");
    await cache.RemoveAsync($"category:{categoryId}:top10"); // derived view
}

TTL is your safety net, not your invalidation strategy. Use tag-based invalidation (HybridCache, Redis hash tags, or CacheManager) when one write should bust many keys.

Step 3: Use HybridCache (.NET 9+) for two-level caching

// Program.cs β€” single API for L1 (in-memory) + L2 (Redis), with stampede protection
builder.Services.AddStackExchangeRedisCache(o =>
    o.Configuration = builder.Configuration.GetConnectionString("Redis"));

builder.Services.AddHybridCache(options =>
{
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration      = TimeSpan.FromMinutes(10),  // L2 (Redis) absolute
        LocalCacheExpiration = TimeSpan.FromMinutes(1) // L1 (in-process)
    };
});

// Usage β€” single GetOrCreateAsync; HybridCache deduplicates concurrent misses
public Task<Product?> GetAsync(int id, CancellationToken ct) =>
    cache.GetOrCreateAsync(
        key: $"product:{id}",
        factory: async ct => await db.Products.FindAsync([id], ct),
        cancellationToken: ct);

Step 4: TTL design β€” three time horizons

Volatile data (prices, stock):       30s – 2 min
Profile / catalog (semi-static):     5 – 30 min
Reference data (zip codes, lookups): hours – days  (or refresh on deploy)

Add 10–20% jitter to TTLs to avoid synchronized expiration ("herd").

Step 5: HTTP-layer caching with the right headers

Cache-Control: public, max-age=300, stale-while-revalidate=60
ETag: "v=42"
Vary: Accept-Encoding, Accept-Language

# stale-while-revalidate lets the CDN serve stale for 60s while it
# refetches in the background β€” zero user-visible latency on revalidation.

Step 6: Stampede protection β€” request coalescing

// Without coalescing, 1000 concurrent requests on a cold key β†’ 1000 DB queries.
// HybridCache coalesces by default. Manual pattern with SemaphoreSlim:
private static readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();

public async Task<T> GetOrLoadAsync<T>(string key, Func<Task<T>> loader)
{
    if (await TryGet<T>(key) is { } hit) return hit;

    var sem = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
    await sem.WaitAsync();
    try
    {
        if (await TryGet<T>(key) is { } afterWait) return afterWait;  // double-check
        var value = await loader();
        await Set(key, value);
        return value;
    }
    finally
    {
        sem.Release();
    }
}

Pitfalls

1. Thundering herd / cache stampede. A popular key expires; thousands of requests miss simultaneously and all hit the DB. Solutions: request coalescing (HybridCache does this), early refresh (refresh at 80% of TTL, not at 100%), and TTL jitter.

2. Cache penetration β€” the "not found" flood. A bot probes for non-existent product IDs; every request misses, every request hits DB. Cache the negative result with a short TTL (cache.Set(key, null, 30s)) or use a Bloom filter.

3. The cache is now load-bearing.When Redis goes down, the DB takes the full traffic and dies. Add a circuit breaker and an L1 in-process cache so a Redis outage degrades, doesn't cascade.

4. Stale data after a write. You wrote to DB, but the cache still has the old value for 10 minutes. Either invalidate on write (cache-aside + explicit Remove) or write-through. TTL alone is a guess.

5. Caching personalised data behind a shared key. GET /api/recommendations served from CDN with noVary: Authorizationships user A's recs to user B. Either don't cache, or include the user id in the key.

6. Keys without a versioning strategy. You change the shape of the cached object and old entries deserialize to null / crash. Prefix keys with a schema version (product:v2:42) and bump on shape changes.

Practical Takeaways

  • Default to cache-aside. Switch only when you have a specific reason.
  • L1 (in-memory) + L2 (Redis) via HybridCache is the modern .NET default β€” one API, stampede-safe.
  • Invalidate on the write path. TTL is the safety net, not the strategy.
  • Add jitter to TTLs (10–20%) to avoid synchronized expiration.
  • Cache the "not found" result with a short TTL to defeat penetration probes.
  • Always have a fallback for the cache being down β€” circuit breaker + in-memory L1.
  • Version your keys. The day you change the DTO shape you'll be glad you did.