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

💡 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

AspectIn-Memory (IMemoryCache)Distributed (Redis)
Lives inOne server's RAMA separate shared store
Shared across servers?No — each server has its ownYes — all servers see it
Survives app restart?NoYes
SpeedFastest (no network)Fast (one network hop)
StoresAny object, as-isBytes/strings (must serialise)
Best forSingle server, hot dataMultiple servers, shared state
ExpirationWhat it doesUse when
AbsoluteDies at a fixed time regardless of useYou need a hard freshness ceiling
SlidingTimer resets on each read; dies after a quiet gapKeep hot data warm, drop idle data
BothSliding keeps it warm; absolute caps the ageThe 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 -> 9

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.

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 hour

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.

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

Common Errors (and the fix)

📋 Quick Reference

TaskCodeNotes
Register in-memory cachebuilder.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 RedisAddStackExchangeRedisCache(...)Distributed cache
Redis set / getSetStringAsync / GetStringAsyncSerialise 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

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