Skip to main content
ANVISoftware Solutions
Lesson 19 of 23Advanced18 min

Caching

By the end of this lesson

Apply response and output caching, and recognise where caching causes harm.

Caching is keeping the result of work so the work does not have to happen again. It is the cheapest large performance improvement available, and it is also where the most confusing bugs come from, because a cached system can be correct for every request except the one that matters.

The easy part is storing a value. The hard part is knowing when the stored value stopped being true. That problem is called invalidation, and no amount of configuration removes it — you either expire things on a timer, or you actively remove them when the underlying data changes, or you accept staleness on purpose.

Decide which of those three you are doing before you add a cache. A cache added without that decision is a source of wrong answers that nobody can reproduce.

Caching happens in several places. They are not interchangeable:

Client and proxy caching
Driven by the Cache-Control headers you send. The response is stored outside your application entirely, which is the fastest option and the one you have least control over once sent. You cannot recall it early.
Response caching middleware
The older in-process cache that honours HTTP caching headers. It respects Cache-Control and Vary, and it deliberately declines to cache a great deal. Adequate for simple public content.
Output caching
The newer server-side option, and the one to reach for in new work. You configure policies in code rather than relying on headers, and you can evict entries by tag, which finally makes deliberate invalidation practical.
IMemoryCache
An in-process dictionary with expiry, for caching values rather than whole responses. Fast, and local to one instance — two servers hold two separate caches with no coordination.
IDistributedCache
The same idea backed by a shared store such as Redis or SQL Server. Every instance sees the same entries, at the cost of serialisation and a network hop. This is what you need once you run more than one instance and the cache must agree.
Output caching with a policy, and eviction when the data changes
C#
builder.Services.AddOutputCache(options =>
{
    // Nothing is cached unless an endpoint opts in.
    options.AddBasePolicy(policy => policy.NoCache());

    options.AddPolicy("DepartmentList", policy => policy
        .Expire(TimeSpan.FromMinutes(5))
        .SetVaryByQuery("page", "pageSize")
        .Tag("departments"));
});

WebApplication app = builder.Build();

app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseOutputCache();      // after authentication and authorization
app.MapControllers();

// ---

[HttpGet]
[OutputCache(PolicyName = "DepartmentList")]
public async Task<ActionResult<IReadOnlyList<DepartmentSummary>>> List(
    int page = 1,
    int pageSize = 25,
    CancellationToken cancellationToken = default)
{
    return Ok(await departments.ListAsync(page, pageSize, cancellationToken));
}

// ---

[HttpPost]
public async Task<IActionResult> Create(
    CreateDepartmentRequest request,
    [FromServices] IOutputCacheStore cacheStore,
    CancellationToken cancellationToken)
{
    DepartmentSummary created = await departments.CreateAsync(request, cancellationToken);

    // The list is now wrong. Remove every entry carrying this tag.
    await cacheStore.EvictByTagAsync("departments", cancellationToken);

    return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
}
  • A base policy of NoCache means caching is opt-in per endpoint. The opposite default makes it too easy for an endpoint to be cached by accident, which is the failure mode later in this lesson.
  • Expire sets how long an entry stays usable. SetVaryByQuery adds the named query values to the cache key, so page 2 does not serve page 1's rows. Anything that changes the response and is not in the key is a bug waiting for traffic.
  • Tag groups related entries so they can be removed together.
  • EvictByTagAsync after a write is the active invalidation half. Without it, a newly created department is missing from the list for up to five minutes, and somebody will report that as a save failure.
  • UseOutputCache is registered after authentication and authorization. Put it earlier and the cache can serve a stored response before the framework has established who is asking.
IMemoryCache for a value that is read constantly and changes rarely
C#
public sealed class DepartmentLookup(
    HrDbContext db,
    IMemoryCache cache,
    ILogger<DepartmentLookup> logger) : IDepartmentLookup
{
    private const string AllDepartmentsKey = "departments:all";

    public async Task<IReadOnlyList<DepartmentSummary>> GetAllAsync(
        CancellationToken cancellationToken)
    {
        IReadOnlyList<DepartmentSummary>? cached = await cache.GetOrCreateAsync(
            AllDepartmentsKey,
            async entry =>
            {
                entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);

                logger.LogInformation("Department list cache miss, loading from database");

                return (IReadOnlyList<DepartmentSummary>)await db.Departments
                    .AsNoTracking()
                    .OrderBy(d => d.Name)
                    .Select(d => new DepartmentSummary(d.Id, d.Name, d.Code))
                    .ToListAsync(cancellationToken);
            });

        return cached ?? [];
    }

    // Called by the write path, not on a timer.
    public void Invalidate() => cache.Remove(AllDepartmentsKey);
}
  • GetOrCreateAsync either returns the stored value or runs the factory and stores what it produced. One method, so there is no gap between checking and setting.
  • AbsoluteExpirationRelativeToNow is the safety net: even if invalidation is missed somewhere, the entry is wrong for at most ten minutes. Prefer absolute expiry over sliding expiry for reference data, because a sliding window on a constantly read key never expires at all.
  • The cache miss is logged. Without that line you cannot tell whether the cache is working, and a cache that misses every time is pure overhead.
  • Invalidate is deliberate and called from wherever departments are written. A cache with only a timer is a decision to be wrong for up to that long, which is sometimes fine — say it out loud rather than discovering it.
  • This cache is per process. Two instances behind a load balancer will disagree for up to ten minutes after a change. If that is not acceptable, this needs to be IDistributedCache instead.

Do not cache, or think much harder before caching:

  • Anything that varies by user, unless the identity is in the key
  • Data where being minutes out of date has a real consequence, such as an account balance or stock level
  • Responses to write requests, and error responses
  • Anything cheap to compute — the lookup may cost more than the work
  • Data you cannot invalidate, because nothing tells your service when it changed
  • A slow endpoint you have not profiled, because the cache will hide the cause rather than remove it

Summary

  • Storing a value is easy; knowing when it stopped being true is the actual problem
  • Output caching is the current server-side option for responses, with policies in code and eviction by tag
  • IMemoryCache is per process, so instances disagree; IDistributedCache is what makes several instances share one view
  • An expiry bounds how long you can be wrong; evicting on write is what makes a change visible immediately, and the expiry should follow how stale the data may be
  • If a response depends on who is asking, the caller must be in the key or the response must not be cached

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

Design the cache key

An endpoint returns a paged list of employees. It accepts page, pageSize and a departmentId filter. Managers see only their own department; HR staff see everyone. The response includes each employee's salary only for HR staff.

List everything the cache key must contain. Then decide whether you would cache this endpoint at all.

Show solution

The key would need page, pageSize, departmentId, and something representing the caller's permission level — because the same three query values produce two different response bodies depending on whether salary is included.

Having written that list, the better answer is not to cache this endpoint. The response depends on identity, the body shape depends on permission, and getting the key wrong discloses salary data. The cost of a mistake is far higher than the cost of the query.

A more defensible approach is to cache further in: cache the department list and any reference data the endpoint needs, and leave the employee query uncached. You keep most of the benefit and the per-user response is never stored.

This is the general shape of good caching decisions. Cache the shared, slow-changing pieces. Leave the per-user composition alone.

Try it yourself

Prove invalidation works

Add output caching with a tag to a list endpoint. Call it twice and confirm the second response is served from the cache, using a log line in the action to show it did not run.

Then create a record without evicting the tag, and call the list again. Finally add the eviction and repeat.

Show solution

Without the eviction, the new record is absent from the list until the expiry passes. The write succeeded, the database is correct, and the API is reporting something else.

This is the exact bug that reaches production as "saving does not work", and it is worth causing on purpose once so you recognise the symptom. The report will be about the write, and the fault is in the read.

With EvictByTagAsync in the write path, the next call is a miss and the list is correct immediately. Note that this still only coordinates within one cache: across several instances you need a shared store or a way to broadcast the eviction.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

An endpoint returning the caller's own profile is cached with the request path as the key. What happens?
Why is active invalidation usually needed alongside an expiry?

Saved in this browser only.