← All posts

4 min read

Five caching strategies, and what .NET actually gives you for each

Cache-aside, read-through, write-through, write-around, write-back — what problem each one solves, and the concrete .NET 9 / ASP.NET Core building blocks for building it.

dotnet caching architecture performance

Every caching strategy is really an answer to two independent questions. On a read, who talks to the database when the cache misses the application, or the cache itself? On a write, does the database get updated before the response goes out, or after? Five established patterns answer those two questions differently, and reaching for the wrong one for your access pattern is usually where the interesting bugs start. View image

Read strategies

Cache-aside (lazy loading)

The application checks the cache first, falls back to the database on a miss, and explicitly populates the cache before returning. It's the default strategy because it's the most explicit one the application always knows exactly what happened on any given request.

.NET 9 gives this a real home instead of hand-rolled IMemoryCache checks. HybridCache.GetOrCreateAsync() does the lookup, the database fallback, and the population in a single call, coordinating an in-process L1 memory cache with an out-of-process L2 distributed cache like Redis automatically:

var product = await hybridCache.GetOrCreateAsync(
    $"product:{id}",
    async ct => await db.Products.FindAsync([id], ct),
    cancellationToken: ct);

The part IMemoryCache never gave you for free is in there too stampede protection. A thousand concurrent requests for the same missing key collapse into one database call instead of a thousand racing each other to repopulate it.

Read-through

The application asks a cache layer for data and never finds out whether the answer came from the cache or the database that decision is made inside the layer, not at the call site.

ASP.NET Core has no first-party read-through cache you build one by wrapping IMemoryCache or HybridCache inside a repository, or as a MediatR pipeline behavior sitting in front of the query handler. The controller asks an IProductRepository, or sends a GetProductQuery, and has no idea EF Core was ever bypassed.

The difference from cache-aside is architectural, not behavioral the same lookup then fetch logic still runs, it's just been moved behind a seam the caller can't see.

Synchronous write strategies

Write-through

Every write updates the cache and the database in the same request, before the response goes out:

await db.SaveChangesAsync(ct);
await distributedCache.SetAsync(key, payload, ct);
return Ok();

That ordering is the whole guarantee. The row is durable before the cache is touched, so a crash between those two lines leaves the cache stale — never wrong-and-trusted. The cost is paid up front: every write waits on two round trips instead of one, which is the price of cache and database never disagreeing.

Write-around

Writes go straight to the database and skip the cache entirely. Updating a cache entry that nobody is about to read is wasted work, so instead of writing the new value in, you just remove the old one:

await db.SaveChangesAsync(ct);
await cache.RemoveAsync(key, ct);

The next read is a guaranteed miss, and cache-aside repopulates it from there. That miss is the entire point — it's the cost of not warming a cache entry for data that's written often and read rarely.

Asynchronous write strategy

Write-back (write-behind)

The write lands in the cache and returns to the caller immediately. Database persistence happens afterward, off the request path.

The risk is obvious: data that exists only in Redis when the process dies is data that never reaches SQL Server. Built properly in .NET, that risk gets an explicit durability boundary — write to Redis, then push the same payload onto a thread-safe System.Threading.Channels.Channel<T>, and let a long-running BackgroundService drain the channel and batch-insert into SQL Server:

public sealed class WriteBehindQueue : BackgroundService
{
    private readonly Channel<OrderWrite> _channel = Channel.CreateUnbounded<OrderWrite>();

    public ValueTask EnqueueAsync(OrderWrite write, CancellationToken ct) =>
        _channel.Writer.WriteAsync(write, ct);

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var batch = new List<OrderWrite>(capacity: 100);
        await foreach (var write in _channel.Reader.ReadAllAsync(stoppingToken))
        {
            batch.Add(write);
            if (batch.Count < 100 && _channel.Reader.TryPeek(out _)) continue;
            await FlushAsync(batch, stoppingToken);
            batch.Clear();
        }
    }
}

Batching the inserts is what turns a flood of single-row writes into a handful of bulk ones, which is most of where the latency and database pressure actually go away. What you're trading for it is a window bounded by the channel depth and the flush interval where a crash loses writes the caller already believes succeeded. Fine for view counts and telemetry. Not fine for anything with a balance.

What each one actually costs

Strategy Who does what .NET building block What you're trading
Cache-aside App checks cache, falls back to DB, populates cache HybridCache.GetOrCreateAsync() Almost nothing some latency on a miss
Read-through Cache layer owns the DB fallback IMemoryCache / HybridCache behind a repository or MediatR behavior Callers can't see or reason about a miss
Write-through DB then cache, same request SaveChangesAsync() + IDistributedCache.SetAsync() Every write pays for two round trips
Write-around DB only cache entry invalidated SaveChangesAsync() + IDistributedCache.RemoveAsync() A guaranteed miss on the next read
Write-back Cache first, DB asynchronously Channel<T> + BackgroundService A durability window between ack and persistence

The actual question

Not "which caching strategy is best" it's whether your data is read far more than it's written, written far more than it's read, or needs the cache and the database to never disagree even for a moment. Cache-aside covers the first case by default. Write-around covers the second. Write-through exists for the third, and write-back only belongs where being wrong for a few seconds costs nothing.