Skip to main content

Redis Caching Patterns in ASP.NET Core: Cache-Aside, Stampede Protection & Distributed Locking

A practical, production-tested guide to Redis caching in ASP.NET Core — cache-aside with IDistributedCache, jittered TTLs, cache stampede prevention with distributed locks, and cache invalidation strategies that don't leave stale data behind.

· 15 min read · Intermediate to Advanced

Key takeaways

  • Implement the cache-aside pattern correctly with IDistributedCache and StackExchange.Redis.
  • Prevent cache stampedes (thundering herd) on expiry with per-key distributed locking.
  • Apply jittered TTLs so thousands of keys don't expire in the same millisecond.
  • Choose the right invalidation strategy: TTL-based, event-based, or write-through.
  • Avoid the concurrency and serialization bugs that quietly corrupt cached data under load.

Every ASP.NET Core API eventually hits the same wall: the database is doing more work than it needs to because the same rows get queried thousands of times a minute with no meaningful change between reads. Redis solves that problem cleanly, but "just add a cache" is where most implementations start going wrong — not because Redis is hard to use, but because the failure modes (stampedes, stale reads, serialization mismatches) don't show up in a demo. They show up three weeks into production, at 2am, during a traffic spike.

Diagram showing an ASP.NET Core API reading from Redis on cache hit and falling back to SQL Server on cache miss, guarded by a distributed lock
Cache-aside architecture: Redis absorbs read traffic, SQL Server is the source of truth on a miss.

1. Cache-Aside with IDistributedCache: The Pattern Done Right

IDistributedCache is the abstraction ASP.NET Core gives you over any distributed cache backend, and StackExchange.Redis is the implementation you register for Redis specifically. The cache-aside pattern itself is simple: check the cache first, and only go to the database on a miss, populating the cache on the way back out. The part people get wrong is doing that check-then-populate sequence without any coordination between concurrent requests.

// Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "arahimx:";
});

// ProductService.cs
public class ProductService
{
    private readonly IDistributedCache _cache;
    private readonly AppDbContext _db;
    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);

    public ProductService(IDistributedCache cache, AppDbContext db)
    {
        _cache = cache;
        _db = db;
    }

    public async Task<ProductDto?> GetProductAsync(int id, CancellationToken ct)
    {
        var cacheKey = 
quot;product:{id}"; var cached = await _cache.GetStringAsync(cacheKey, ct); if (cached is not null) { return JsonSerializer.Deserialize<ProductDto>(cached, JsonOptions); } var product = await _db.Products.AsNoTracking() .Where(p => p.Id == id) .Select(p => new ProductDto(p.Id, p.Name, p.Price)) .FirstOrDefaultAsync(ct); if (product is not null) { var payload = JsonSerializer.Serialize(product, JsonOptions); await _cache.SetStringAsync(cacheKey, payload, new DistributedCacheEntryOptions { // Jittered TTL — see section 3 on why the +/- randomness matters AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10 + Random.Shared.Next(-2, 3)), }, ct); } return product; } }

Notice AsNoTracking() on the EF Core query — data that's about to be serialized into a cache entry has no business being tracked by the change tracker, and skipping it avoids the memory and CPU overhead of snapshotting for changes that will never be saved. Also notice this code caches null results nowhere: a product that doesn't exist gets re-queried on every request. For most catalogs that's fine; if you're getting hammered with requests for IDs that don't exist (a classic cache-penetration attack pattern), cache a short-lived sentinel value for misses too.

2. Cache Stampede Protection with Distributed Locking

Here's the failure mode that doesn't show up until you have real traffic: a popular cache key expires, and in the same instant, five hundred concurrent requests all get a cache miss and all hit the database simultaneously to repopulate it. That's a cache stampede — also called the thundering herd problem — and it can take down a database that was otherwise handling load fine, precisely because the cache was doing its job well enough to mask how much load it was absorbing.

Flowchart of the cache-aside read path showing cache hit and cache miss branches with a distributed lock guarding the miss path
The miss path acquires a per-key lock before querying SQL Server, so only one request repopulates the cache.

RedLock.net wraps Redis's SET NX EX pattern into a proper distributed lock implementation, including safe release and lock renewal. The fix is to make only one of those five hundred requests actually query the database — the rest wait briefly and then read the value that request just wrote.

// Requires: RedLock.net NuGet package
public async Task<ProductDto?> GetProductWithLockAsync(int id, CancellationToken ct)
{
    var cacheKey = 
quot;product:{id}"; var cached = await _cache.GetStringAsync(cacheKey, ct); if (cached is not null) { return JsonSerializer.Deserialize<ProductDto>(cached, JsonOptions); } var resource =
quot;lock:product:{id}"; await using var redLock = await _distributedLockFactory.CreateLockAsync( resource, expiryTime: TimeSpan.FromSeconds(10), waitTime: TimeSpan.FromSeconds(3), retryTime: TimeSpan.FromMilliseconds(150)); if (!redLock.IsAcquired) { // Another request is already repopulating this key — retry the read once. var retry = await _cache.GetStringAsync(cacheKey, ct); return retry is not null ? JsonSerializer.Deserialize<ProductDto>(retry, JsonOptions) : null; } // Re-check inside the lock: another request may have already repopulated it // while we were waiting to acquire the lock (double-checked locking). cached = await _cache.GetStringAsync(cacheKey, ct); if (cached is not null) { return JsonSerializer.Deserialize<ProductDto>(cached, JsonOptions); } var product = await _db.Products.AsNoTracking() .Where(p => p.Id == id) .Select(p => new ProductDto(p.Id, p.Name, p.Price)) .FirstOrDefaultAsync(ct); if (product is not null) { await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(product, JsonOptions), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) }, ct); } return product; }

3. Jittered TTLs: Why Uniform Expiry Times Are a Trap

If you seed a cache with a few thousand keys in a tight loop — a common pattern during a deploy or a warm-up job — and give every single one an identical 10-minute TTL, all of them expire within the same second ten minutes later. That's a self-inflicted stampede, and it's avoidable with a couple of lines: add a small random offset to every TTL so expirations spread out across a window instead of landing on the same tick.

public static TimeSpan JitteredTtl(TimeSpan baseTtl, double jitterPercent = 0.15)
{
    var jitterRange = baseTtl.TotalSeconds * jitterPercent;
    var offset = Random.Shared.NextDouble() * jitterRange * 2 - jitterRange;
    return TimeSpan.FromSeconds(Math.Max(1, baseTtl.TotalSeconds + offset));
}

// Usage: 10 minutes +/- 15%, instead of a hard 10:00 for every key
await _cache.SetStringAsync(cacheKey, payload,
    new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = JitteredTtl(TimeSpan.FromMinutes(10)) }, ct);

4. Cache Invalidation: TTL, Event-Based, and Write-Through

There's a well-worn joke that cache invalidation is one of the two genuinely hard problems in computer science, and the reason it's hard isn't technical — it's that the right strategy depends entirely on how stale you're willing to let data get. Three patterns cover almost every real case:

TTL-only invalidation is the simplest: data goes stale for up to the TTL window and self-heals on the next miss. It's fine for product catalogs, config values, and anything where a five-minute-old read is a non-event. Event-based invalidation actively deletes or updates the cache key the moment the underlying row changes — usually from inside the same service method that performs the write, or from a message consumer if writes happen in another service. Write-through keeps the cache updated on every write instead of ever invalidating it, trading a bit of extra write latency for reads that are never stale.

// Event-based invalidation: fired from the same transaction that updates the row
public async Task UpdateProductPriceAsync(int id, decimal newPrice, CancellationToken ct)
{
    var product = await _db.Products.FindAsync([id], ct)
        ?? throw new KeyNotFoundException(
quot;Product {id} not found"); product.Price = newPrice; await _db.SaveChangesAsync(ct); // Invalidate rather than update-in-place: forces the next read to rebuild // the DTO from the just-committed row, avoiding any serialization drift. await _cache.RemoveAsync(
quot;product:{id}", ct); }
Invalidate on write, not on a timer, whenever a stale read would cause a real business problem — a stale price on a checkout page is a refund request waiting to happen. Everywhere else, a TTL is simpler and just as correct.

5. Serialization Pitfalls That Corrupt Cached Data Silently

System.Text.Json's default settings differ subtly from what a lot of teams expect — case sensitivity, enum handling, and how it treats records with required properties can all change behavior between the version that wrote a cache entry and the version that reads it back after a deploy. Two defensive habits prevent almost every real incident here: version your cache keys (product:v2:{id}) so a deploy that changes the DTO shape doesn't deserialize old cached JSON into a broken object, and pin a single static JsonSerializerOptions instance across both the write and read paths instead of letting each call site construct its own.

Summary & Key Takeaway

Cache-aside with IDistributedCache gets you 80% of the win with almost no complexity. The other 20% — stampede protection with distributed locking, jittered TTLs, and a deliberate invalidation strategy — is what separates a cache that quietly helps from one that becomes an outage multiplier the first time it's put under real load. Add those three things before traffic forces you to, not after.

← All guides