Lesson 28 of 45 intermediate

Caching

Keep an expensive answer close, and know when it is stale or unsafe to share

Guess first

Pick an answer. You will see if you were right at the end of the lesson.

Your API runs on four instances behind a load balancer. Which cache gives all four the same cached value?

Open interactive version (quiz + challenge)

Real-world analogy

A busy cafe keeps a jug of filter coffee on the counter. Making each cup from scratch takes four minutes, but pouring from the jug takes seconds. The cost is that the jug can go stale, and the owner must decide when to throw it out and brew a fresh one. If the flavour changes, the old jug must be emptied, or customers get yesterday's coffee. And a cup poured for one customer must never be handed to another. The jug is the cache, brewing is the database query, and emptying it is invalidation.

What is it?

Caching means saving the result of expensive work so you can reuse it. In ASP.NET Core you have IMemoryCache for data inside one process and IDistributedCache, usually backed by Redis, for data shared by all instances. The usual pattern is cache-aside: read the cache, and on a miss load from the database and store the result with an expiry. The hard parts are knowing when cached data is stale, and making sure a cached item is safe to share with whoever asks for it.

Real-world relevance

A product page that reads the same price list from the database ten thousand times an hour is a good cache candidate, since the data is shared and changes rarely. Teams get hurt in two ways. A cache with no invalidation shows old prices after an update. A cache key built without the user id shows one customer's cart to another, which becomes a privacy incident rather than a bug. Both are why reviewers look hard at every cache key.

Key points

Quick check

Answer from memory — scrolling up first makes it stick less.

What is the cache-aside read path?

Why is invalidation called the hard part of caching?

Code example

public record ProductDto(int Id, string Name, decimal Price);

public class ProductCatalog(ShopContext db, IDistributedCache cache)
{
    // Product data is the same for every user, so the key needs only the product id
    private static string Key(int id) => $"product:{id}";

    public async Task<ProductDto?> GetAsync(int id, CancellationToken ct)
    {
        var json = await cache.GetStringAsync(Key(id), ct);
        if (json is not null)
            return JsonSerializer.Deserialize<ProductDto>(json);          // cache hit

        var product = await db.Products.AsNoTracking()
            .Where(p => p.Id == id)
            .Select(p => new ProductDto(p.Id, p.Name, p.Price))
            .FirstOrDefaultAsync(ct);                                     // miss: ask the database
        if (product is null) return null;

        await cache.SetStringAsync(Key(id), JsonSerializer.Serialize(product),
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) }, ct);
        return product;
    }

    public async Task UpdatePriceAsync(int id, decimal price, CancellationToken ct)
    {
        await db.Products.Where(p => p.Id == id)
            .ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, price), ct);
        await cache.RemoveAsync(Key(id), ct);                             // invalidate on write
    }
}

Line-by-line walkthrough

  1. 1. ProductDto is the small shape that gets stored in the cache, serialized as JSON.
  2. 2. Key builds the cache key from the product id. This data is public, so no user id is needed.
  3. 3. GetAsync first asks the cache. A non-null string is a hit, and the method returns it without touching the database.
  4. 4. On a miss, a projected query reads only the three needed columns from the database.
  5. 5. SetStringAsync stores the result for ten minutes, an absolute expiry that acts as a safety net.
  6. 6. UpdatePriceAsync updates the row, then removes the key so the next read loads the fresh price.
  7. 7. This is not perfect. A reader that loaded the old row just before the update can write it back after the removal. The ten-minute expiry limits how long that stale value lives.

Spot the bug

public async Task<CartDto> GetCartAsync(int userId, CancellationToken ct)
{
    var json = await cache.GetStringAsync("cart", ct);
    if (json is not null) return JsonSerializer.Deserialize<CartDto>(json)!;

    var cart = await carts.LoadAsync(userId, ct);
    await cache.SetStringAsync("cart", JsonSerializer.Serialize(cart),
        new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(20) }, ct);
    return cart;
}
Need a hint?
Look at the cache key and ask who else uses the same key.
Show answer
The key is the fixed string "cart", so every user reads and writes the same cache entry. The first user's cart is served to everyone after them. Fix: put the user in the key, for example $"cart:{userId}", in both the read and the write.

Explain like I'm 5

If your friends keep asking you the same question, you write the answer on a card and show the card. Now you must remember to change the card when the answer changes. And you must not show your private card to everyone.

Fun fact

A joke often repeated among developers says there are only two hard things in computer science: cache invalidation and naming things. It is a joke, but anyone who has served stale prices knows the first half is true.

Hands-on challenge

Say it out loud in 30 seconds: "When would you add a cache to an API, and what can go wrong?" Then check: did you mention cache-aside, memory versus distributed cache with multiple instances, expiry and invalidation on write, and keys that include the user or tenant when data is private?

More resources

Open interactive version (quiz + challenge) Download the whole course as PDF / Word

Lock it in

← Back to course: .NET + React Playbook