Caching Strategies
Reviewed & published by Brayan K
By the end of this lesson you'll be able to make a .NET app dramatically faster by serving data from a cache instead of recomputing it or hitting the database every time — using the cache-aside pattern, IMemoryCache, and Redis, with the right expiry and invalidation so your data never goes stale.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Why caching speeds apps up — and when it's worth the trouble
- The cache-aside pattern: check cache, compute on a miss, store the result
- In-memory caching with IMemoryCache and GetOrCreate
- Distributed caching with Redis via IDistributedCache
- Absolute vs sliding expiration, and using both together
- Cache invalidation and preventing a cache stampede
💡 Real-World Analogy
Caching is keeping the items you reach for constantly on your desk instead of walking to the stockroom every time. The stockroom (the database) has everything and is always correct, but it's slow to get to. Your desk (the cache) holds a small set of frequently-used things right where you need them — instant to grab. The catch is the same one every cache has: if the stockroom restocks a newer version, the copy on your desk is now out of date, so you need a rule for when to throw the desk copy away and fetch a fresh one. An in-memory cache is your own personal desk; a distributed cache (Redis) is a shared shelf in the middle of the office that the whole team reads from.
Why Cache At All?
Some work is expensive and repeated: a database query, a call to a third-party API, a heavy calculation. If the answer rarely changes but is asked for thousands of times a second, recomputing it every time is pure waste. A cache stores the answer the first time and hands back the saved copy on every subsequent request — turning a 50 ms database round-trip into a sub-millisecond memory read.
Caching is only worth it when reads vastly outnumber writes and the data tolerates being slightly out of date. The whole craft is managing that staleness: deciding how long a cached value may live (expiration) and removing it the moment the source of truth changes (invalidation). Get those right and caching is the single biggest performance win in most web apps.
📊 In-Memory vs Distributed, and Expiration Types
| Aspect | In-Memory (IMemoryCache) | Distributed (Redis) |
|---|---|---|
| Lives in | One server's RAM | A separate shared store |
| Shared across servers? | No — each server has its own | Yes — all servers see it |
| Survives app restart? | No | Yes |
| Speed | Fastest (no network) | Fast (one network hop) |
| Stores | Any object, as-is | Bytes/strings (must serialise) |
| Best for | Single server, hot data | Multiple servers, shared state |
| Expiration | What it does | Use when |
|---|---|---|
| Absolute | Dies at a fixed time regardless of use | You need a hard freshness ceiling |
| Sliding | Timer resets on each read; dies after a quiet gap | Keep hot data warm, drop idle data |
| Both | Sliding keeps it warm; absolute caps the age | The safe default for most entries |
1. The Cache-Aside Pattern
Almost every cache you'll write follows one shape, called cache-aside (or "lazy loading"): look in the cache first; on a hit return the stored value; on a miss compute it, store it, then return it. A "hit" means the value was already there; a "miss" means it wasn't and you had to do the slow work. Before reaching for any library, it's worth seeing the whole pattern in plain C# with a Dictionary<int,int> standing in for the cache — once you can write it by hand, every caching API is just a tidier version of this. Read the worked example, run it, then you'll write one.
using System;
using System.Collections.Generic;
class Program
{
// The cache: a fast in-memory lookup of id -> already-computed result.
static Dictionary<int, int> cache = new Dictionary<int, int>();
// An "expensive" computation we want to avoid repeating.
// Pretend this hits a database or calls a slow API.
static int ExpensiveSquare(int n)
{
Console.WriteLine($" ...computing {n} squared (slow!)");
return n * n;
}
// CACHE-ASIDE: look in the cache first; only compute on a MISS,
// then STORE the result so next time is a HIT.
static int GetSquare(int n)
{
if (cache.TryGetValue(n, out int cached)) // is it already cached?
return cached; // HIT -> return instantly
int result = ExpensiveSquare(n); // MISS -> compute it
cache[n] = result; // store for next time
return result;
}
static void Main()
{
Console.WriteLine($"5 -> {GetSquare(5)}"); // MISS: computes, stores 25
Console.WriteLine($"5 -> {GetSquare(5)}"); // HIT: no compute, returns 25
Console.WriteLine($"3 -> {GetSquare(3)}"); // MISS: computes, stores 9
}
}
// ✅ Expected output:
// ...computing 5 squared (slow!)
// 5 -> 25
// 5 -> 25
// ...computing 3 squared (slow!)
// 3 -> 9Your turn. This GetLength method is the same cache-aside shape, but two pieces are missing. Fill in the ___ blanks so it returns the cached value on a hit and stores the result on a miss, then run it.
using System;
using System.Collections.Generic;
class Program
{
static Dictionary<string, int> cache = new Dictionary<string, int>();
static int CountLetters(string word)
{
Console.WriteLine($" ...counting letters in '{word}' (slow!)");
return word.Length;
}
// 🎯 YOUR TURN — finish the cache-aside lookup, then run it.
static int GetLength(string word)
{
// 1) Return the cached value if it's already there.
if (cache.TryGetValue(word, out int cached))
return ___; // 👉 return the cached value: cached
// 2) MISS — compute it, then STORE it before returning.
int result = CountLetters(word);
cache[word] = ___; // 👉 store the result: result
return result;
}
static void Main()
{
Console.WriteLine($"cat -> {GetLength("cat")}"); // MISS: computes, stores 3
Console.WriteLine($"cat -> {GetLength("cat")}"); // HIT: returns 3, no compute
Console.WriteLine($"hippo -> {GetLength("hippo")}"); // MISS: computes, stores 5
// ✅ Expected output:
// ...counting letters in 'cat' (slow!)
// cat -> 3
// cat -> 3
// ...counting letters in 'hippo' (slow!)
// hippo -> 5
}
}2. Measuring Hits vs Misses
A cache is only earning its keep if most requests are hits. The hit ratio — hits divided by total lookups — is the number you watch in production: a low ratio means you're caching the wrong things or expiring them too quickly. You can measure it the same way you'd build it: count a hit when the value was already cached and a miss when you had to compute it. Fill in the two blanks so the counters move correctly, then run it.
using System;
using System.Collections.Generic;
class Program
{
static Dictionary<string, string> cache = new Dictionary<string, string>();
static int hits = 0; // counts how often the value was already cached
static int misses = 0; // counts how often we had to compute it
static string Lookup(string key)
{
// 🎯 YOUR TURN — record a HIT or a MISS on each lookup.
if (cache.TryGetValue(key, out string value))
{
// 1) It was in the cache — that's a HIT.
___; // 👉 add one to hits: hits++
return value;
}
// 2) It was NOT in the cache — that's a MISS.
___; // 👉 add one to misses: misses++
value = key.ToUpper(); // pretend this is the "slow" lookup
cache[key] = value;
return value;
}
static void Main()
{
string[] requests = { "a", "b", "a", "a", "b", "c" };
foreach (string r in requests)
Lookup(r); // first "a","b","c" miss; repeats hit
Console.WriteLine($"Hits: {hits}, Misses: {misses}");
// ✅ Expected output:
// Hits: 3, Misses: 3
}
}3. In-Memory Caching with IMemoryCache
In a real .NET app you don't hand-roll a Dictionary — you inject IMemoryCache (registered once with builder.Services.AddMemoryCache()). It's the fastest cache because it lives right in your server's RAM, but it's not shared between servers and it's lost on restart. Its GetOrCreate method is the cache-aside pattern in a single call: it returns the cached value, or runs your factory delegate on a miss and stores the result for you. Note this snippet is application code that needs the ASP.NET packages and a database, so read it rather than running it here.
using Microsoft.Extensions.Caching.Memory;
// GetOrCreate is the cache-aside pattern built in: it checks the cache,
// and only runs your factory delegate (the slow work) on a MISS,
// storing the result so the next call is a HIT.
public class ProductService
{
private readonly IMemoryCache _cache;
private readonly AppDbContext _db;
public ProductService(IMemoryCache cache, AppDbContext db)
{
_cache = cache;
_db = db;
}
public Product? GetProduct(int id)
{
string key = $"product_{id}";
// If "product_42" is cached, return it. If not, run the factory,
// store the result under that key, then return it.
return _cache.GetOrCreate(key, entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
Console.WriteLine($"DB read for {key}"); // only prints on a MISS
return _db.Products.Find(id);
});
}
}
// Calling sequence (same id):
// var s = new ProductService(cache, db);
// s.GetProduct(42); // MISS -> "DB read for product_42", loads from DB
// s.GetProduct(42); // HIT -> no DB read, returns the cached Product
//
// ✅ Expected console output across those two calls:
// DB read for product_42
// (the second call prints nothing — it was served from cache)4. Absolute vs Sliding Expiration
Nothing should live in a cache forever, or it slowly drifts out of sync with the truth. Absolute expiration kills an entry at a fixed time no matter how busy it is — a hard freshness ceiling. Sliding expiration resets the timer every time the entry is read, so popular data stays warm and only idle data is dropped. The danger of sliding alone is that a constantly-read key could live indefinitely and grow stale — so the safe default is to set both: sliding to keep hot data, absolute to cap how old any value can ever get.
using Microsoft.Extensions.Caching.Memory;
// Two ways an entry can expire:
// ABSOLUTE — dies at a fixed time, no matter how often it's used.
// SLIDING — the timer RESETS every time the entry is read; it only
// dies after a quiet gap with no access.
// Use BOTH together: sliding keeps hot data warm, absolute caps how
// stale any value can ever get.
var options = new MemoryCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromMinutes(5)) // reset on each read
.SetAbsoluteExpiration(TimeSpan.FromHours(1)); // hard ceiling
_cache.Set("all_categories", categories, options);
// Timeline example with the options above:
// 00:00 Set -> stored
// 00:04 read -> HIT, sliding timer resets to 00:09
// 00:08 read -> HIT, sliding timer resets to 00:13
// 00:13 (no reads since 00:08) -> sliding window lapsed -> EVICTED
// ...and even on a busy key, at 01:00 the absolute limit forces eviction.
//
// ✅ Rule of thumb:
// sliding alone -> a constantly-read key can live forever (gets stale)
// absolute alone -> popular keys re-load on a fixed schedule
// both together -> hot data stays cached, but never older than 1 hour5. Invalidation — Killing Stale Data
Expiration handles staleness over time, but when you change the underlying data you can't wait for a timer — the cached copy is wrong now. Invalidation means removing (or replacing) the cached entry the instant its source of truth changes, usually right after the database write that changed it. The pattern is simple: write to the database, then _cache.Remove(key) so the very next read misses and reloads the fresh value.
using Microsoft.Extensions.Caching.Memory;
public class ProductService
{
private readonly IMemoryCache _cache;
private readonly AppDbContext _db;
public ProductService(IMemoryCache cache, AppDbContext db)
{
_cache = cache;
_db = db;
}
public Product? GetProduct(int id) =>
_cache.GetOrCreate($"product_{id}", entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
return _db.Products.Find(id);
});
// INVALIDATION: when the underlying data changes, the cached copy is
// now WRONG. Remove it so the next read repopulates from the database.
public void UpdatePrice(int id, decimal newPrice)
{
var product = _db.Products.Find(id);
if (product == null) return;
product.Price = newPrice;
_db.SaveChanges();
_cache.Remove($"product_{id}"); // drop the stale entry
}
}
// Sequence:
// GetProduct(42) -> MISS, caches the product at £20
// GetProduct(42) -> HIT, returns the cached £20
// UpdatePrice(42, 25) -> writes £25 to the DB, REMOVES the cache entry
// GetProduct(42) -> MISS again, reloads £25 from the DB
//
// ✅ Without the Remove(...) line, the third read would return the
// stale £20 until the 10-minute absolute expiry kicked in.6. Distributed Caching with Redis
The moment your app runs on more than one server, in-memory cache becomes a liability: each server caches its own copy, so they disagree and an invalidation on one never reaches the others. A distributed cache solves this — Redis is a separate, fast key-value store that every server reads from and writes to, so they all see the same data and it survives a restart. The trade-off: Redis stores bytes, so your objects must be serialised (typically to JSON) on the way in and deserialised on the way out, and each access costs one small network hop.
using Microsoft.Extensions.Caching.Distributed;
using System.Text.Json;
// Program.cs — register Redis as the distributed cache.
// In-memory cache lives inside ONE server's RAM; a distributed cache
// (Redis) is a separate shared store every server reads from, so all
// your instances see the same data and it survives an app restart.
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost:6379";
});
public class SessionStore
{
private readonly IDistributedCache _cache;
public SessionStore(IDistributedCache cache) => _cache = cache;
// Redis stores bytes/strings, so objects must be serialised (here, JSON).
public async Task SaveAsync(string userId, UserSession session)
{
string json = JsonSerializer.Serialize(session);
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(8),
SlidingExpiration = TimeSpan.FromMinutes(30)
};
await _cache.SetStringAsync($"session:{userId}", json, options);
}
public async Task<UserSession?> LoadAsync(string userId)
{
string? json = await _cache.GetStringAsync($"session:{userId}");
return json is null ? null : JsonSerializer.Deserialize<UserSession>(json);
}
}
// Sequence on a 3-server web farm behind a load balancer:
// Server A: SaveAsync("u1", session) -> written to Redis
// Server B: LoadAsync("u1") -> HIT (reads the SAME Redis entry)
// Server C: LoadAsync("u1") -> HIT as well
//
// ✅ With IMemoryCache this would fail: B and C have their own RAM and
// would each MISS, because A's copy never left A's memory.7. Preventing a Cache Stampede
Here's the failure mode that bites teams in production: a popular cache entry expires, and in that instant dozens or hundreds of in-flight requests all miss at once and pile onto the database together. That's a cache stampede (or "dogpiling"), and it can take a database down precisely when traffic is highest. The fix is to let one request rebuild the entry while the rest wait on a lock — a SemaphoreSlim does this cleanly. Always re-check the cache after acquiring the lock, because another thread may have already filled it.
using Microsoft.Extensions.Caching.Memory;
// CACHE STAMPEDE (a.k.a. "dogpiling"): a hot key expires, and dozens of
// requests all MISS at the same instant and hammer the database together.
// Fix: let ONE request rebuild the entry while the others wait.
public class CatalogService
{
private readonly IMemoryCache _cache;
private readonly AppDbContext _db;
// One lock guarding the rebuild of this key.
private static readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1);
public CatalogService(IMemoryCache cache, AppDbContext db)
{
_cache = cache;
_db = db;
}
public async Task<List<Category>> GetCategoriesAsync()
{
const string key = "all_categories";
if (_cache.TryGetValue(key, out List<Category>? cached))
return cached!; // fast path: already cached
await _lock.WaitAsync(); // only ONE thread enters here
try
{
// Re-check: another thread may have filled the cache while we waited.
if (_cache.TryGetValue(key, out cached))
return cached!;
var data = await _db.Categories.ToListAsync(); // the single DB hit
_cache.Set(key, data, TimeSpan.FromMinutes(30));
return data;
}
finally
{
_lock.Release();
}
}
}
// ✅ With 100 simultaneous requests on an expired key:
// WITHOUT the lock -> ~100 database reads (the stampede)
// WITH the lock -> exactly 1 database read; the other 99 wait,
// then read the freshly-cached value🔎 Deep Dive: don't reinvent this — reach for a library
The hand-written SemaphoreSlim guard above is correct, but doing it for every cache key gets repetitive and easy to get subtly wrong (one lock per key, double-check inside, exception safety). In real code, a library like FusionCache or HybridCache (built into .NET 9) gives you stampede protection, a two-level cache (in-memory in front of Redis), and "stale-while-revalidate" out of the box.
// HybridCache (.NET 9) — cache-aside + stampede protection in one call:
var product = await cache.GetOrCreateAsync(
$"product_{id}",
async ct => await db.Products.FindAsync(id, ct),
new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(10) });Learn the pattern by hand first (you just did), then let the library carry it in production. Knowing what's underneath is what lets you debug it when it misbehaves.
Pro Tips
- 💡 Always set an expiry. A cache with no expiration is a slow memory leak — entries pile up until the server runs out of RAM.
- 💡 Default to absolute + sliding together: sliding keeps hot data warm, absolute guarantees nothing is ever older than your ceiling.
- 💡 Invalidate on write, don't just wait for the timer: remove the key in the same method that updates the database.
- 💡 Namespace your keys like "product_42" or "session:u1" so different data types never collide.
- 💡 Cache per-user data under a per-user key (include the user id) — never under one shared key, or users will see each other's data.
- 💡 Measure the hit ratio. If it's low, you're caching the wrong things or expiring too aggressively — caching that mostly misses is just overhead.
Common Errors (and the fix)
- Stale data after an update: you changed the database but the app keeps showing the old value. You forgot to invalidate — add _cache.Remove(key) right after the write, or the entry lingers until its expiry.
- Unbounded growth / out-of-memory: entries with no expiry (or no SizeLimit on the cache) accumulate forever. Always set an absolute and/or sliding expiration, and consider SetSize with a cache SizeLimit.
- One user sees another's data: you cached per-user data under a global key like "current_user". Include the user id in the key — $"user_{userId}" — so each user gets their own entry.
- Cache stampede under load: a hot key expires and many requests hit the DB at once. Guard the rebuild with a SemaphoreSlim (re-checking inside the lock) or use a library like FusionCache / HybridCache.
- "InvalidOperationException: ... requires a serializer" / nothing comes back from Redis: you tried to store a plain object in IDistributedCache. Serialise to a string/bytes (e.g. JsonSerializer.Serialize) before SetStringAsync, and deserialise on read.
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| Register in-memory cache | builder.Services.AddMemoryCache() | Once, in Program.cs |
| Cache-aside in one call | _cache.GetOrCreate(key, factory) | Factory runs only on a miss |
| Read if present | _cache.TryGetValue(key, out v) | true = hit |
| Absolute expiry | .SetAbsoluteExpiration(ts) | Hard time limit |
| Sliding expiry | .SetSlidingExpiration(ts) | Resets on each read |
| Invalidate | _cache.Remove(key) | After a write |
| Register Redis | AddStackExchangeRedisCache(...) | Distributed cache |
| Redis set / get | SetStringAsync / GetStringAsync | Serialise to JSON first |
Frequently Asked Questions
Q: When should I use IMemoryCache vs Redis?
Use IMemoryCache for a single server or for tiny, hot data where being lost on restart is fine — it's the fastest option. Use Redis (a distributed cache) the moment you run on more than one server, or need the cache to survive restarts and be shared, like user sessions.
Q: What's the difference between absolute and sliding expiration?
Absolute expires at a fixed time no matter how often the entry is used. Sliding resets the timer on every read, so frequently-used data stays cached and only idle data is dropped. Set both: sliding keeps hot data warm; absolute caps how stale it can ever get.
Q: How do I stop a cache from serving out-of-date data?
Two tools: expiration (the value can't be older than X) and invalidation (remove the key the instant the underlying data changes). For data that's edited, invalidate on write — don't rely on the timer alone.
Q: What's a cache stampede and how do I prevent it?
It's when a hot key expires and many requests miss simultaneously, all hammering the database at once. Let one request rebuild the entry while the others wait — a SemaphoreSlim (re-checking the cache inside the lock), or a library like FusionCache / HybridCache that does it for you.
Q: Should I cache absolutely everything?
No. Caching pays off when reads vastly outnumber writes and slight staleness is acceptable. Data that changes constantly, or must always be perfectly current, is a poor fit — and a cache that mostly misses just adds overhead.
Mini-Challenge: a TTL Cache
No blanks this time — just a brief and an outline. Build a tiny cache that stores each value alongside the DateTime it was added, and expires it on read once it's older than a time-to-live (TTL). This is exactly how the expiry logic inside IMemoryCache works under the hood. Run it and check that a fresh read hits while a read past the TTL misses.
using System;
using System.Collections.Generic;
class Program
{
// 🎯 MINI-CHALLENGE: a tiny cache with a TTL (time-to-live)
//
// Store each value together with the DateTime it was added, then
// EXPIRE it on read if it's older than the TTL.
//
// 1. Make a Dictionary that maps a string key to a tuple
// (int value, DateTime storedAt). e.g.
// var cache = new Dictionary<string, (int value, DateTime storedAt)>();
// 2. Choose a TTL, e.g. var ttl = TimeSpan.FromSeconds(2);
// 3. Write Get(key): if the key is missing -> MISS.
// If present but (DateTime.Now - storedAt) > ttl -> EXPIRED (remove it, MISS).
// Otherwise -> HIT, return the value.
// 4. Write Set(key, value): store (value, DateTime.Now).
//
// Try it:
// Set("x", 10); // store
// Console.WriteLine(Get("x")); // HIT -> 10
// // ...wait past the TTL (e.g. Thread.Sleep beyond 2s)...
// Console.WriteLine(Get("x")); // EXPIRED -> MISS
//
// ✅ Expected behaviour: a fresh read HITs and returns the value;
// a read after the TTL has elapsed MISSes (entry expired & removed).
static void Main()
{
// your code here
}
}🎉 Lesson Complete
- ✅ Cache-aside: check the cache, compute on a miss, store the result
- ✅ A hit ratio tells you whether a cache is actually paying off
- ✅ IMemoryCache.GetOrCreate is cache-aside in a single call (fast, per-server, lost on restart)
- ✅ Absolute caps the age; sliding keeps hot data warm — use both
- ✅ Invalidate on write with Remove(key); don't wait for the timer
- ✅ Redis (IDistributedCache) is shared across servers and survives restarts
- ✅ Guard hot keys against a stampede with a SemaphoreSlim or a caching library
Practice quiz
What does the cache-aside pattern do?
- Always writes to the cache before the database
- Never stores anything, only reads
- Checks the cache first; on a miss it computes the value, stores it, then returns it
- Replaces the database entirely
Answer: Checks the cache first; on a miss it computes the value, stores it, then returns it. Cache-aside (lazy loading): look in the cache first; on a hit return it, on a miss compute it, store it, then return it.
In cache terms, what is a 'hit'?
- The value was already in the cache and returned without recomputing
- The value was not in the cache and had to be computed
- The cache was cleared
- The cache key was invalid
Answer: The value was already in the cache and returned without recomputing. A hit means the value was already cached; a miss means it wasn't there and the slow work had to run.
Which IMemoryCache method is cache-aside in a single call, running the factory only on a miss?
- TryGetValue
- Set
- Remove
- GetOrCreate
Answer: GetOrCreate. GetOrCreate returns the cached value or runs your factory delegate on a miss and stores the result for you.
How does sliding expiration behave?
- The entry dies at a fixed time no matter what
- The timer resets on each read, so the entry only dies after a quiet gap with no access
- The entry never expires
- The entry expires immediately after the first read
Answer: The timer resets on each read, so the entry only dies after a quiet gap with no access. Sliding expiration resets the timer every time the entry is read, keeping hot data warm and dropping only idle data.
Why combine absolute and sliding expiration?
- Sliding keeps hot data warm while absolute caps how stale any value can ever get
- It makes lookups faster
- It disables expiration entirely
- It is required by IMemoryCache
Answer: Sliding keeps hot data warm while absolute caps how stale any value can ever get. Sliding alone lets a constantly-read key live forever and grow stale; absolute provides a hard freshness ceiling on top.
What is cache invalidation?
- Waiting for the timer to expire an entry
- Checking whether a cache hit occurred
- Removing (or replacing) the cached entry the instant its source of truth changes
- Increasing the cache size limit
Answer: Removing (or replacing) the cached entry the instant its source of truth changes. Invalidation removes the stale entry right after the write that changed the data, so the next read reloads the fresh value — usually via _cache.Remove(key).
What is the main difference between IMemoryCache and a distributed cache like Redis?
- IMemoryCache survives restarts; Redis does not
- IMemoryCache lives in one server's RAM (not shared); Redis is a shared store every server reads from and it survives restarts
- Redis is always slower than IMemoryCache because it has no network hop
- There is no difference
Answer: IMemoryCache lives in one server's RAM (not shared); Redis is a shared store every server reads from and it survives restarts. IMemoryCache is per-server RAM, fastest but not shared and lost on restart. Redis is a shared distributed store that survives restarts.
Why must objects be serialised before storing them in Redis (IDistributedCache)?
- To encrypt them
- To make them immutable
- Redis does not require serialisation
- Because Redis stores bytes/strings, so objects are typically serialised to JSON on the way in
Answer: Because Redis stores bytes/strings, so objects are typically serialised to JSON on the way in. A distributed cache stores bytes, so objects must be serialised (typically to JSON) on write and deserialised on read.
What is a cache stampede (dogpiling)?
- A cache that grows without limit
- A hot key expires and many requests all miss at once, hammering the database together
- Two caches returning different values
- A cache that never expires entries
Answer: A hot key expires and many requests all miss at once, hammering the database together. A stampede happens when a popular entry expires and dozens of requests miss simultaneously, all piling onto the database.
How can you prevent a cache stampede in a hand-rolled cache?
- Disable caching entirely
- Increase the timeout to infinity
- Let one request rebuild the entry behind a SemaphoreSlim while the others wait, re-checking the cache inside the lock
- Store the value twice
Answer: Let one request rebuild the entry behind a SemaphoreSlim while the others wait, re-checking the cache inside the lock. A SemaphoreSlim lets a single request rebuild the entry while the rest wait; re-check inside the lock since another thread may have filled it.
Continue this course
- Previous: Role-Based, Policy-Based & Claims-Based Security
- Next: Real-Time Communication with SignalR — Build live dashboards and chat with SignalR hubs and JavaScript clients
- Quick reference: C# cheat sheet