Caching
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.
Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.
Part of the free C# course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.