Advanced Track
Caching Strategies
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.
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
IMemoryCache) and async/await, since real caches are wired in through DI and accessed asynchronously.💡 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.
Worked example: cache-aside with a Dictionary
Run it and watch the 'slow' compute only fire on a miss — the repeat is a hit.
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
...Your 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.
🎯 Your turn: complete the cache-aside lookup
Fill in the two ___ blanks, then check 'cat' is only computed once.
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
...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.
🎯 Your turn: count cache hits vs misses
Increment hits on a cache hit and misses on a miss; expect Hits: 3, Misses: 3.
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 ca
...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.
Worked example: IMemoryCache.GetOrCreate
The factory delegate only runs on a miss — the second call is served from cache.
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)
...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.
Worked example: absolute + sliding together
Read the timeline — sliding keeps it warm, absolute forces a refresh by 1 hour.
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
.S
...5. 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.
Worked example: invalidate on write
After UpdatePrice removes the entry, the next read reloads the new price from the DB.
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);
...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.
Worked example: IDistributedCache with Redis
One server writes a session; the others read the same entry from shared Redis.
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 IDist
...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.
Worked example: stampede prevention with SemaphoreSlim
One thread rebuilds the entry; the rest wait, then read the freshly-cached value.
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);
p
...🔎 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
SizeLimiton the cache) accumulate forever. Always set an absolute and/or sliding expiration, and considerSetSizewith a cacheSizeLimit. - 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) beforeSetStringAsync, 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.
🎯 Mini-Challenge: build a TTL cache
Store (value, storedAt), then expire entries on read once they're older than the TTL.
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 =
...🎉 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.GetOrCreateis 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
SemaphoreSlimor a caching library - ✅ Next lesson: SignalR — push real-time updates from server to client
Sign up for free to track which lessons you've completed and get learning reminders.