Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 036 / 100

Distributed caching with Redis: best practices explained

The patterns that kept a high-traffic e-commerce API fast.

Expona Admin8 min read

Distributed caching with Redis: The unsung hero of high-traffic platforms

When your e-commerce API is handling thousands of concurrent requests and your database is starting to sweat, caching isn't optional—it's survival. Over the last five years, I've watched Redis transform sluggish platforms into snappy ones, but only when teams follow disciplined patterns instead of treating it as a magic bullet.

This post covers the five patterns that kept our trading and e-commerce APIs fast under real load. These aren't theoretical. They're what we still use across production systems handling hundreds of requests per second.

Cache-aside by default — Read from cache, fall back to DB, populate cache

The cache-aside pattern (also called lazy loading) is the safest starting point. The flow is simple: check the cache, miss? hit the database, then populate the cache for next time.

Why this pattern? It's forgiving. If your cache goes down, your application keeps working. Your data is never stale by design—only as fresh as your TTL allows. And you only cache data that's actually requested.

Here's a practical implementation:

csharp
public class ProductService
{
    private readonly IDatabase _redis;
    private readonly ProductDbContext _db;
    private readonly ILogger<ProductService> _logger;

    public async Task<Product> GetProductAsync(int productId)
    {
        var cacheKey = $"product:{productId}";
        
        // Try cache first
        var cached = await _redis.StringGetAsync(cacheKey);
        if (cached.HasValue)
        {
            _logger.LogInformation("Cache hit for product {ProductId}", productId);
            return JsonSerializer.Deserialize<Product>(cached.ToString());
        }

        // Cache miss — fetch from DB
        _logger.LogInformation("Cache miss for product {ProductId}", productId);
        var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId);
        
        if (product != null)
        {
            // Populate cache with TTL
            var json = JsonSerializer.Serialize(product);
            await _redis.StringSetAsync(cacheKey, json, TimeSpan.FromMinutes(5));
        }

        return product;
    }
}

This pattern is battle-tested. It's predictable, easy to debug, and your application degrades gracefully if Redis is unavailable. The trade-off is eventual consistency—freshly written data takes up to five minutes to appear in reads from other servers.

Pick keys deliberately — Namespace and version your keys

Here's where most teams make their first mistake: they use simple keys like product:123 and then spend weeks wondering why old data is being served after a schema change.

I learned this the hard way. We deployed a new product schema with additional fields, but the cache still served the old structure to half our users. The fix required a manual Redis flush that cost us visibility into cache behavior for hours.

The solution is deliberate key design. Include a namespace and version:

csharp
public class CacheKeyBuilder
{
    private const string Version = "v2";
    
    public static string ProductKey(int productId) => $"{Version}:product:{productId}";
    public static string UserKey(int userId) => $"{Version}:user:{userId}";
    public static string InventoryKey(string sku) => $"{Version}:inventory:{sku}";
    public static string CategoryListKey() => $"{Version}:category:list";
}

When you ship a breaking schema change, bump the version to v3. All old entries are instantly stale. No manual flushing, no confusion. This is especially valuable in microservices where multiple teams own different cache namespaces.

I also recommend using consistent naming: <version>:<entity>:<identifier>. Your teammates will thank you when debugging production issues at 2 AM.

Always set a TTL — Even five minutes saves you from stale data

A TTL (time to live) is non-negotiable. Without it, forgotten cache keys live forever, and one missed invalidation becomes a production incident.

The TTL you choose depends on your domain. Product catalogs? Fifteen minutes is safe. User preferences? Two minutes. Shopping cart data? One minute. Real-time inventory? Thirty seconds or even write-through caching.

Look at this mistake I see often:

csharp
// WRONG: No TTL specified
await _redis.StringSetAsync(cacheKey, serializedValue);

// RIGHT: Always include expiry
await _redis.StringSetAsync(cacheKey, serializedValue, TimeSpan.FromMinutes(5));

Without the TTL parameter, that key stays in Redis until manual deletion. Six months later, you're digging through old cache entries wondering why they're still there.

One nuance: don't use the same TTL everywhere. Differentiate by entity type. A product view can live for fifteen minutes. An order status should expire in two minutes. This gives you fine-grained control over the staleness-to-load trade-off.

Serialize efficiently — System.Text.Json with source generators, or MessagePack

Serialization overhead matters when you're storing megabytes of cache per second. At scale, I've seen serialization become a genuine CPU bottleneck.

System.Text.Json with source generators is my default choice for most scenarios. It's built-in, fast, and requires minimal setup:

csharp
public partial class SerializerContext : JsonSerializerContext
{
}

[JsonSerializable(typeof(Product))]
[JsonSerializable(typeof(Order))]
public partial class DefaultJsonSerializerContext : JsonSerializerContext
{
}

public class CacheService
{
    private readonly DefaultJsonSerializerContext _context = new();

    public async Task SetAsync<T>(string key, T value, TimeSpan ttl)
    {
        var json = JsonSerializer.Serialize(value, _context.GetTypeInfo(typeof(T)) as JsonTypeInfo<T>);
        await _redis.StringSetAsync(key, json, ttl);
    }

    public async Task<T> GetAsync<T>(string key)
    {
        var cached = await _redis.StringGetAsync(key);
        return cached.HasValue 
            ? JsonSerializer.Deserialize<T>(cached.ToString(), _context.GetTypeInfo(typeof(T)) as JsonTypeInfo<T>)
            : default;
    }
}

For large objects (trading datasets, complex aggregations), I've moved to MessagePack. It's binary, compact, and significantly faster:

csharp
public async Task SetAsync<T>(string key, T value, TimeSpan ttl)
{
    var bytes = MessagePackSerializer.Serialize(value);
    await _redis.StringSetAsync(key, bytes, ttl);
}

public async Task<T> GetAsync<T>(string key)
{
    var cached = await _redis.StringGetAsync(key);
    return cached.HasValue 
        ? MessagePackSerializer.Deserialize<T>(cached.Value)
        : default;
}

The trade-off: MessagePack is less human-readable if you're debugging in the Redis CLI. For most production systems, that's fine. For complex caching logic you'll iterate on, stick with JSON until it's proven to be a bottleneck.

Protect against stampedes — Locking and HybridCache

Imagine a hot key: v2:product:999 is accessed 500 times per second. Its TTL expires at exactly 14:00:05. In the next 10 milliseconds, 50 concurrent requests miss the cache simultaneously. All 50 hit the database for the same product.

This is a cache stampede. The database gets hammered by redundant queries. If that query is expensive (involving joins, aggregations), you've just created a denial-of-service condition on yourself.

The solution is distributed locking. When a cache miss occurs, acquire a lock, fetch the data once, populate the cache, and release the lock. Other waiting threads recheck the cache:

csharp
public async Task<Product> GetProductWithLockAsync(int productId)
{
    var cacheKey = $"product:{productId}";
    var lockKey = $"{cacheKey}:lock";

    // Try cache first
    var cached = await _redis.StringGetAsync(cacheKey);
    if (cached.HasValue)
    {
        return JsonSerializer.Deserialize<Product>(cached.ToString());
    }

    // Try to acquire lock (expires in 5 seconds)
    var lockValue = Guid.NewGuid().ToString();
    var acquiredLock = await _redis.StringSetAsync(
        lockKey, 
        lockValue, 
        TimeSpan.FromSeconds(5), 
        When.NotExists
    );

    if (!acquiredLock)
    {
        // Couldn't acquire lock — wait and retry cache
        await Task.Delay(100);
        var retried = await _redis.StringGetAsync(cacheKey);
        return retried.HasValue 
            ? JsonSerializer.Deserialize<Product>(retried.ToString())
            : null;
    }

    try
    {
        // Fetch from DB
        var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId);
        
        if (product != null)
        {
            var json = JsonSerializer.Serialize(product);
            await _redis.StringSetAsync(cacheKey, json, TimeSpan.FromMinutes(5));
        }

        return product;
    }
    finally
    {
        // Release lock
        var currentLock = await _redis.StringGetAsync(lockKey);
        if (currentLock == lockValue)
        {
            await _redis.KeyDeleteAsync(lockKey);
        }
    }
}

This ensures only one request pays for a cache miss. The others wait briefly and recheck the cache.

In .NET 8+, consider using HybridCache (the new abstraction in .NET). It handles distributed cache coordination, local in-memory caching, and stampede protection out of the box:

csharp
public class ProductService
{
    private readonly HybridCache _cache;
    private readonly ProductDbContext _db;

    public async Task<Product> GetProductAsync(int productId)
    {
        return await _cache.GetOrCreateAsync(
            $"product:{productId}",
            async cancel =>
            {
                return await _db.Products
                    .AsNoTracking()
                    .FirstOrDefaultAsync(p => p.Id == productId, cancel);
            },
            new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(5) }
        );
    }
}

HybridCache gives you the best of both worlds: local in-memory cache for frequently accessed data plus distributed Redis coordination. It's becoming my go-to for new projects.

Common mistakes

  • Caching without TTL. Keys accumulate forever. Redis memory grows unbounded. Set a TTL, always.
  • Assuming cache invalidation is trivial. It's notoriously hard. Stamped keys and explicit invalidation are safer than time-based expiry alone for mutable data.
  • Caching before measuring. Profile your database queries first. Cache the slow ones, not everything. Premature caching adds complexity without benefit.
  • Ignoring serialization overhead. Large objects serialized on every request add latency. For high-volume caches, choose efficient serializers and measure.
  • Not handling cache failures gracefully. If Redis is down, your app should still work (slower, but functional). Catch connection exceptions and fall through to the database.

Key takeaways

  • Use cache-aside by default. It's safe, forgiving, and works at any scale.
  • Namespace and version your keys deliberately. Bump the version on schema changes to invalidate everything safely.
  • Always set a TTL, even if it's conservative. It's insurance against stale data and runaway memory usage.
  • Choose your serializer based on data size and iteration speed. System.Text.Json for most cases, MessagePack for large objects.
  • Protect against cache stampedes with distributed locks or HybridCache. One database hit per miss, not 50.
  • Cache is not a replacement for database optimization. Profile first, cache strategically, measure the impact.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles