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.
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.
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.