Section 05

Caching: strategies, expiration & pitfalls

A cache keeps a copy of data somewhere faster, so you don't repeat expensive work. It's the cheapest performance win, and the easiest way to serve stale or wrong data.

5.1 Caching basics: where can you cache? Added

Cache as close to the user as you can, for as long as the data can safely be stale.

Browser / appCache-Control
→
CDN edgestatic, public GETs
→
API gateway / APIMresponse cache
→
App in-memoryL1, per instance
→
Distributed cacheRedis, L2, shared
→
Databasebuffer pool

✅ Good to cache

  • Read-heavy, rarely changing data (reference data, product catalogue, config)
  • Expensive results (aggregations, third-party API calls)
  • Session data, tokens, rate-limit counters

❌ Be careful

  • Balances, stock levels, anything that must be exactly right
  • Per-user sensitive data in shared caches (PII, auth)
  • Data that changes on almost every write
  • Hit ratio = hits ÷ (hits + misses). Aim high (often >80–90%) or the cache isn't paying for itself.
  • "There are only two hard things in computer science: cache invalidation and naming things." (Phil Karlton)
How to implement it
Library / APIPackageUse it for
HybridCache ⭐Microsoft.Extensions.Caching.HybridDefault choice: in-memory L1 + distributed L2 in one API, with stampede protection and tag invalidation
IMemoryCachebuilt-in Microsoft.Extensions.Caching.MemoryPer-instance in-process cache (L1 only)
IDistributedCacheMicrosoft.Extensions.Caching.StackExchangeRedis (or .SqlServer)Shared cache across all instances (L2)
Output cachingbuilt-in ASP.NET Core (AddOutputCache); Redis store: Microsoft.AspNetCore.OutputCaching.StackExchangeRedisCache whole HTTP responses on the server
Response cachingbuilt-in [ResponseCache] / Cache-Control headersLet browsers and CDNs cache responses
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddStackExchangeRedisCache(o =>
    o.Configuration = builder.Configuration.GetConnectionString("redis")); // L2 (shared)
builder.Services.AddHybridCache();   // L1 memory + the L2 registered above
builder.Services.AddOutputCache();   // full-response cache

var app = builder.Build();
app.UseOutputCache();
app.MapGet("/products", GetProducts)
   .CacheOutput(p => p.Expire(TimeSpan.FromMinutes(5)).Tag("products"));

5.2 Caching strategies (read & write patterns)

They answer two questions: who loads the cache on a read, and what happens to the cache on a write?

1. Cache-aside (lazy loading) ⭐ most common

App
→1 get
Cache
miss →2 read
DB
→3 set
Cache
  • The app manages the cache. Data is loaded only when someone asks for it.
  • ➕ Cache failure isn't fatal. Only hot data gets cached.
  • ➖ First request is slow (miss). Data can go stale.

2. Read-through

App
→
Cacheloads from DB on miss
→
DB
  • The cache library or provider fetches from the DB on a miss.
  • ➕ Simpler app code.
  • ➖ Needs a cache that supports loaders. Same first-miss cost.

3. Write-through

App
→write
Cache
→sync
DB
  • Every write goes to the cache and the DB at the same time.
  • ➕ Cache is always fresh. Reads are fast.
  • ➖ Slower writes. Caches data nobody may read.

4. Write-behind (write-back)

App
→
Cache
⇢async batch
DB
  • Write to the cache now, flush to the DB later.
  • ➕ Very fast writes. Absorbs spikes.
  • ➖ Data loss risk if the cache dies before flushing.

5. Write-around

App
→write
DB
…
Cachefilled on later read
  • Writes skip the cache. Usually paired with cache-aside.
  • ➕ Data that's written but never read doesn't fill the cache.
  • ➖ A read right after a write is a miss.

6. Refresh-ahead

Cache
↻refresh before TTL ends
DB
  • Hot keys reload in the background just before they expire.
  • ➕ No miss spikes on hot data.
  • ➖ Wasted work if you predict wrong.
StrategyRead speedWrite speedFreshnessRiskUse when
Cache-asideFast after first missNormalStale until TTL or invalidationLowGeneral purpose, read-heavy
Read-throughSameNormalSameLowYou want cleaner app code
Write-throughFastSlowerHighLowData read soon after it's written
Write-behindFastFastestHigh in cacheData lossCounters, metrics, high write rates
Write-aroundMiss after writeNormalHighLowWrite-once, rarely read (logs)
Refresh-aheadFastestNormalHighWasted loadsPredictable hot keys
  • With cache-aside, on update: write the DB, then delete the cache key. Don't update the key, because two racing writers can leave the wrong value behind.
  • Always set a TTL as a safety net, even when you also invalidate explicitly.
How to implement it
StrategyLibrary / APINotes
Cache-aside / read-throughHybridCache.GetOrCreateAsyncChecks the cache, runs your factory on a miss, then stores the result. Effectively read-through
Write-throughHybridCache.SetAsync after the DB writeWrite the DB and cache in the same code path
Write-behindbuilt-in System.Threading.Channels + BackgroundServiceQueue writes in memory and flush them to the DB in batches. Accept the data-loss risk, or use a durable queue
Refresh-aheadZiggyCreatures.FusionCache (EagerRefreshThreshold)Refreshes hot keys in the background before they expire. FusionCache also adds fail-safe: it serves stale data if the DB is down
public sealed class ProductService(HybridCache cache, AppDbContext db)
{
    // Cache-aside: on a miss, the factory runs once and the result is cached
    public ValueTask<Product?> GetAsync(int id, CancellationToken ct) =>
        cache.GetOrCreateAsync($"product:{id}",
            async token => await db.Products.FindAsync([id], token),
            cancellationToken: ct);

    public async Task UpdateAsync(Product product, CancellationToken ct)
    {
        db.Products.Update(product);
        await db.SaveChangesAsync(ct);                       // 1. write the DB
        await cache.RemoveAsync($"product:{product.Id}", ct); // 2. then delete the key
    }
}

6 caching strategies every engineer should know 🧠

1️⃣ Cache-aside: app checks the cache, loads from the DB on a miss

2️⃣ Read-through: cache loads from the DB for you

3️⃣ Write-through: write cache + DB together

4️⃣ Write-behind: write cache now, DB later (fast but risky)

5️⃣ Write-around: writes skip the cache

6️⃣ Refresh-ahead: refresh hot keys before they expire

Which one runs your system? 👇 #SystemDesign #Caching #Redis

5.3 Cache expiration strategies

Expiration decides when an entry stops being valid. (Eviction decides what to throw out when memory is full.)

⏳ Absolute TTL

Expires X seconds after it's written. Simple and predictable. Example: FX rates, TTL 60 s.

🔄 Sliding expiration

The timer resets on each access. Good for sessions ("log out after 15 min idle"). Hot data may never refresh, so add an absolute cap.

🎯 Explicit / event-based invalidation

Delete or update the key when the source changes, triggered by the write path, an event or CDC. Freshest, but more moving parts.

🏷 Versioned keys

product:42:v7. Bump the version on change and old keys age out. Great for CDN assets (app.3f9a.js).

🌓 Stale-while-revalidate

Serve the stale value right away and refresh in the background. Users never wait.

🎲 TTL with jitter

TTL = 300 s ± random 30 s, so many keys don't expire at the same moment (prevents an avalanche).

HTTP caching headers (browser, CDN, APIM)

HeaderMeaning
Cache-Control: max-age=600Fresh for 10 minutes
s-maxage=3600TTL for shared caches (CDN) only
no-cacheMay store, but must revalidate before use
no-storeNever store (sensitive data)
private / publicBrowser only / CDN allowed
ETag + If-None-MatchServer replies 304 Not Modified if unchanged, which saves bandwidth
  • Pick the TTL from the business rule: "How stale is acceptable?" Not from a guess.
  • Short TTL = fresher but more DB load. Long TTL = cheaper but staler.
  • Invalidating across instances: use pub/sub (Redis keyspace notifications) or events to clear L1 caches.
How to implement it
Expiration typeLibrary / API
Absolute TTLHybridCacheEntryOptions.Expiration / MemoryCacheEntryOptions.AbsoluteExpirationRelativeToNow
SlidingMemoryCacheEntryOptions.SlidingExpiration (add an absolute cap)
Event / tag invalidationHybridCache.RemoveAsync / RemoveByTagAsync; output cache IOutputCacheStore.EvictByTagAsync
Change tokensCancellationChangeToken / IChangeToken on IMemoryCache entries
HTTP caching[ResponseCache], Response.GetTypedHeaders().CacheControl, EntityTagHeaderValue for ETag / 304
// TTL with jitter + tags, so a whole group can be invalidated by one event
var options = new HybridCacheEntryOptions
{
    Expiration = TimeSpan.FromMinutes(5) + TimeSpan.FromSeconds(Random.Shared.Next(30)),
    LocalCacheExpiration = TimeSpan.FromMinutes(1)
};
var rate = await cache.GetOrCreateAsync($"fx:{pair}", LoadRateAsync, options, tags: ["fx"]);
await cache.RemoveByTagAsync("fx");      // e.g. when a "RatesPublished" event arrives

// Sliding expiration with an absolute cap (sessions)
memoryCache.Set(sessionId, session, new MemoryCacheEntryOptions
{
    SlidingExpiration = TimeSpan.FromMinutes(15),
    AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(8)
});

RemoveAsync / RemoveByTagAsync invalidate this server's L1 and the shared L2, but not the in-memory L1 on other servers. Keep LocalCacheExpiration short, so stale L1 copies age out quickly.

5.4 Cache eviction policies Added

When the cache is full, which entry goes?

PolicyEvictsGood for
LRU: Least Recently UsedEntry not touched for the longest timeDefault choice. Recent = likely to be used again
LFU: Least Frequently UsedEntry with the fewest hitsStable popularity (top products)
FIFOOldest insertedSimple, ignores usage
MRUMost recently usedScans where the recent item won't be reused
RandomAny entryCheap, surprisingly OK
TTL-basedEntry closest to expiryRedis volatile-ttl

Implementation: HashMap + doubly linked list gives O(1) get and put. The map finds the entry, and the list keeps the usage order (move to front on access, drop from the tail when full). A classic interview question.

  • Redis policies: noeviction, allkeys-lru, volatile-lru, allkeys-lfu, volatile-lfu, allkeys-random, volatile-ttl. "volatile" = only keys that have a TTL.
How to implement it
Library / APIUse it for
MemoryCacheOptions.SizeLimit + entry SizeCap the in-process cache. On compaction, low-priority and least-recently-used entries go first
CacheItemPriority (Low … NeverRemove)Protect important entries from eviction
MemoryCache.Compact(0.25)Free 25% of entries on demand
Redis maxmemory-policyA server setting (e.g. allkeys-lru), not client code. Set it in Azure Managed Redis / Azure Cache for Redis

Interview version: an O(1) LRU in C# with Dictionary + LinkedList (not thread-safe; wrap it in a Lock if it's shared):

public sealed class LruCache<TKey, TValue>(int capacity) where TKey : notnull
{
    private readonly Dictionary<TKey, LinkedListNode<(TKey Key, TValue Value)>> _map = new();
    private readonly LinkedList<(TKey Key, TValue Value)> _list = new();

    public bool TryGet(TKey key, out TValue value)
    {
        if (_map.TryGetValue(key, out var node))
        {
            _list.Remove(node); _list.AddFirst(node);      // now most recently used
            value = node.Value.Value; return true;
        }
        value = default!; return false;
    }

    public void Put(TKey key, TValue value)
    {
        if (_map.TryGetValue(key, out var existing)) _list.Remove(existing);
        else if (_map.Count == capacity)
        {
            var lru = _list.Last!;                          // least recently used
            _list.RemoveLast(); _map.Remove(lru.Value.Key);
        }
        _map[key] = _list.AddFirst((key, value));
    }
}

5.5 Cache problems & fixes Added

The classic ways caches fail in production, and the standard fix for each.

🐘 Cache stampede (thundering herd)

A hot key expires and 10,000 requests hit the DB at once.

  • Lock or request coalescing (only one request rebuilds)
  • Stale-while-revalidate, refresh-ahead
  • Probabilistic early expiry

🕳 Cache penetration

Requests for keys that don't exist always miss and hit the DB (often an attack).

  • Cache "null" results with a short TTL
  • Bloom filter of valid keys (see 5.8)
  • Input validation, rate limiting

🏔 Cache avalanche

Many keys expire together, or the cache cluster goes down, and the DB is flooded.

  • TTL jitter
  • Highly available cache cluster, multi-level cache
  • Circuit breaker and rate limiting on the DB

🔥 Hot key

One key (a celebrity or a promotion) overloads a single cache node.

  • Local L1 cache in front
  • Replicate the key (key#1..#N)

🔀 Inconsistency

Cache and DB disagree after a race or a failed invalidation.

  • Update the DB, then delete the key
  • Delayed double delete
  • CDC-driven invalidation (Debezium → delete key)

🧳 Big keys

Huge values block a single-threaded Redis and saturate the network.

  • Split values, compress, use hashes
How to implement it
ProblemLibrary / API
StampedeHybridCache: concurrent callers for the same key share one factory call. FusionCache: the same, plus eager refresh
PenetrationCache "not found" results with a short TTL. Bloom filter: NRedisStack (db.BF(), needs Redis with the Bloom module, e.g. Azure Managed Redis)
AvalancheTTL jitter (Random.Shared), FusionCache fail-safe (serve stale data), a Polly circuit breaker in front of the DB (Microsoft.Extensions.Resilience)
Hot keyThe L1 in HybridCache absorbs repeat reads on each instance
InconsistencyUpdate the DB, then RemoveAsync; or CDC-driven invalidation (Cosmos DB change feed / Kafka consumer → RemoveAsync)
// Penetration guard: reject IDs that can't exist before touching the cache or DB
var bloom = redis.GetDatabase().BF();
if (!await bloom.ExistsAsync("accounts:valid", accountId))
    return Results.NotFound();

5.6 CDN (Content Delivery Network) Added

A worldwide network of edge servers that cache content close to users, cutting latency and load on your origin.

User in Perth
→
Edge POP (Perth)HIT → ~10 ms
miss →
Origin (Sydney)App / Blob storage
  • Purging is slow and costly. Prefer versioned file names (cache busting).
  • Watch the cache key: query strings and headers can fragment the cache.
How to implement it
Library / APIPackageUse it for
MapStaticAssetsbuilt-in ASP.NET CoreFingerprinted file names, build-time compression, ETags and long cache headers. CDN-friendly cache busting
Cache headersbuilt-in [ResponseCache] / CacheControlHeaderValuepublic, max-age, s-maxage for edge caching
PurgeAzure.ResourceManager.Cdn (Front Door / CDN endpoints)Purge content from a release pipeline when versioned names aren't possible

5.7 Redis vs Memcached Added

RedisMemcached
Data typesStrings, hashes, lists, sets, sorted sets, streams, JSON, vectorsStrings only
PersistenceOptional (RDB snapshots, AOF log)None
Replication / HAYes (replicas, Sentinel, Cluster)No (client-side sharding)
ExtrasPub/sub, Lua, TTLs, rate limiting, locks, leaderboardsSimple, multi-threaded
Pick whenAlmost always todayPure, simple key-value cache at huge scale
How to implement it
PackageUse it for
StackExchange.RedisThe standard Redis client: strings, hashes, sorted sets, pub/sub, Lua, transactions
Microsoft.Extensions.Caching.StackExchangeRedisIDistributedCache on Redis (the L2 for HybridCache)
NRedisStackRedis modules: JSON, Search and vector search, Bloom, time-series
Aspire.StackExchange.RedisAspire integration: connection, health checks and telemetry wired up for you
EnyimMemcachedCore (community)Memcached client, if you really need Memcached

5.8 Probabilistic data structures: Bloom filter, HyperLogLog, Count-Min Sketch Added

Tiny, fast structures that give an approximate answer ("probably yes", "about 1.2 million", "roughly 340 times") using a fraction of the memory an exact answer needs.

🌸 Bloom filter

"Have we seen this before?"

  • Answers "definitely not" or "probably yes"
  • No false negatives. False positives are tunable (1% needs about 10 bits per item)
  • Can't delete items

🐦 Cuckoo filter

Same question as a Bloom filter, but it supports delete and is often smaller at low error rates.

🔢 HyperLogLog (HLL)

"How many unique items?"

  • Count distinct with a fixed, tiny memory
  • Redis: 12 KB per key, about 0.81% standard error, for counts into the billions
  • Can merge (daily → monthly uniques)

📊 Count-Min Sketch

"How often has X appeared?"

  • Frequency per item in a fixed grid of counters
  • May over-count, never under-counts

🏆 Top-K

"What are the K most frequent items?" Heavy hitters in a stream: top merchants, hottest cache keys, noisiest API clients.

How a Bloom filter works

When to use which

QuestionStructureExampleError type
Is X in the set?Bloom filterCache-penetration guard (see 5.5): reject account IDs that don't exist before hitting the DB. "Have we already processed this payment ID?" (then confirm in the DB)False positives only
Is X in the set, and I need to remove items?Cuckoo filterActive session IDs, revoked tokens that expireFalse positives only
How many unique?HyperLogLogUnique cards used today, unique visitors per pageAbout ±0.81% (Redis)
How often?Count-Min SketchCard attempts per merchant in the last hour (fraud velocity)Over-counts only
What's most frequent?Top-KTop 10 hottest cache keys, top merchants, top error codesApproximate ranking
  • Bloom filter: a "no" is always right; a "yes" means "go and check". Never use it as the final answer for money or security decisions.
  • Use them when the exact answer is too big or too slow and a small error is fine: counting, deduplicating, filtering at huge scale.
  • In Redis, HyperLogLog is a core type. Bloom, Cuckoo, Count-Min Sketch and Top-K come from RedisBloom, which is built into Redis 8 and available in Azure Managed Redis.
  • Interview line: "exact needs memory that grows with the data; probabilistic needs fixed memory and trades a little accuracy."
How to implement it
Library / APIPackageUse it for
HyperLogLogStackExchange.Redis (HyperLogLogAddAsync, HyperLogLogLengthAsync, HyperLogLogMergeAsync)Approximate unique counts in core Redis
Bloom / CuckooNRedisStack (db.BF(), db.CF())Shared filters across all instances: ReserveAsync (error rate, capacity), AddAsync, ExistsAsync; Cuckoo adds DelAsync
Count-Min Sketch / Top-KNRedisStack (db.CMS(), db.TOPK())Frequencies (IncrByAsync, QueryAsync) and heavy hitters (AddAsync, ListAsync)
In-memory Bloom filterbuilt-in System.Collections.BitArray + the System.IO.Hashing package (XxHash3)A per-process filter when you don't need Redis. .NET has no built-in Bloom filter type
// Redis: unique cards today (HLL) and card attempts per merchant (CMS)
await db.HyperLogLogAddAsync($"uniq:cards:{day}", cardToken);
long uniqueCards = await db.HyperLogLogLengthAsync($"uniq:cards:{day}");   // about ±0.81%

var cms = db.CMS();                                     // NRedisStack
await cms.IncrByAsync($"attempts:{hour}", merchantId, 1);
long[] attempts = await cms.QueryAsync($"attempts:{hour}", merchantId);

// In memory: a small Bloom filter with double hashing
public sealed class BloomFilter(int bits, int hashes)
{
    private readonly BitArray _bits = new(bits);

    public void Add(string item) { foreach (var i in Positions(item)) _bits[i] = true; }

    public bool MightContain(string item)          // false = definitely not added
    {
        foreach (var i in Positions(item)) if (!_bits[i]) return false;
        return true;
    }

    private IEnumerable<int> Positions(string item)
    {
        ulong h = XxHash3.HashToUInt64(Encoding.UTF8.GetBytes(item));
        ulong h1 = (uint)h, h2 = h >> 32;
        for (ulong k = 0; k < (ulong)hashes; k++) yield return (int)((h1 + k * h2) % (ulong)bits);
    }
}

Size a Bloom filter for the number of items you expect. Overfill it and the false-positive rate climbs fast. Rebuild it (or use Redis's scaling filters) when the data grows.

Your cache doesn't need to know everything. Sometimes "probably" is enough 🎯

🌸 Bloom filter: "Have we seen this ID?" A "no" is always right. A "yes" means "go and check".

🔢 HyperLogLog: count unique users in 12 KB with under 1% error.

📊 Count-Min Sketch: "how often?" for millions of keys in fixed memory.

🏆 Top-K: the heavy hitters in a stream, live.

Exact answers need memory that grows with your data. These need a fixed, tiny amount. Where would a 1% error be fine in your system?

#SystemDesign #DataStructures #Redis #Caching

⚡ Quick-fire Q&A

Expiration vs eviction?

Expiration is time or validity based (TTL). Eviction is space based (LRU/LFU when memory is full).

Why delete instead of update the cache on write?

Two concurrent writers can update the cache in the opposite order to the DB and leave stale data. Deleting forces the next read to load the true value.

Would you cache an account balance?

Generally no, or only with a very short TTL, clearly labelled "as at" time, and never for authorising transactions. Correctness beats speed.

Can a Bloom filter give a false negative?

No. If an item was added, all its bits are 1, so the filter always says "probably yes". It can give false positives, because other items may have set the same bits. That's why a "yes" is followed by a real lookup.

How would you count unique daily users across 50 million events cheaply?

A HyperLogLog per day (Redis PFADD / PFCOUNT): 12 KB per key, about 0.81% error. Merge the daily keys (PFMERGE) for weekly or monthly uniques. Use an exact set only if the number must be exact.

← 04 · Distributed Systems