Learnixo
Back to blog
AI Systemsintermediate

IDistributedCache — Shared Caching with Redis in ASP.NET Core

IDistributedCache with Redis: setup, serialization, expiration, cache-aside pattern, and the distributed caching patterns that ensure consistency across multiple API instances.

Asma Hafeez KhanMay 16, 20265 min read
CachingIDistributedCacheRedisASP.NET Core.NET
Share:𝕏

Why IDistributedCache

IMemoryCache is per-process. IDistributedCache is shared — all instances read and write the same cache. Required when your API runs on 2 or more servers/containers.

IDistributedCache with Redis:
  Storage:   Redis server (external process)
  Speed:     ~0.5ms per operation (network + serialization overhead)
  Lifetime:  survives API restarts
  Sharing:   ALL instances share one cache
  Use when:  2+ instances, or you need cache to survive deploys

Common Redis providers for ASP.NET Core:
  Microsoft.Extensions.Caching.StackExchangeRedis (official)
  StackExchange.Redis (lower level)

Registration

C#
// Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration         = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName          = "SystemForge:";  // prefix all keys
    options.ConfigurationOptions = new ConfigurationOptions
    {
        AbortOnConnectFail = false,   // continue if Redis is unavailable
        ConnectRetry       = 3,
        ConnectTimeout     = 5000,
    };
});
JSON
// appsettings.json
{
  "ConnectionStrings": {
    "Redis": "localhost:6379,password=yourpassword,ssl=false"
  }
}

Basic Operations

C#
public sealed class PatientCacheService
{
    private readonly IDistributedCache    _cache;
    private readonly PatientRepository   _repo;

    public PatientCacheService(IDistributedCache cache, PatientRepository repo)
        => (_cache, _repo) = (cache, repo);

    private static string Key(Guid id) => $"patient:{id}";

    public async Task<PatientDto?> GetByIdAsync(Guid id, CancellationToken ct)
    {
        var bytes = await _cache.GetAsync(Key(id), ct);
        if (bytes is not null)
            return JsonSerializer.Deserialize<PatientDto>(bytes);

        var patient = await _repo.GetByIdAsync(id, ct);
        if (patient is null) return null;

        var dto = patient.ToDto();
        await SetAsync(id, dto, ct);
        return dto;
    }

    private async Task SetAsync(Guid id, PatientDto dto, CancellationToken ct)
    {
        var bytes   = JsonSerializer.SerializeToUtf8Bytes(dto);
        var options = new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30),
            SlidingExpiration               = TimeSpan.FromMinutes(5),
        };
        await _cache.SetAsync(Key(id), bytes, options, ct);
    }

    public async Task InvalidateAsync(Guid id, CancellationToken ct)
        => await _cache.RemoveAsync(Key(id), ct);
}

Serialization

C#
// IDistributedCache stores byte[]. Serialize your objects.

// Option 1: System.Text.Json (recommended — fast, no dependencies)
var bytes = JsonSerializer.SerializeToUtf8Bytes(dto,
    new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
var dto   = JsonSerializer.Deserialize<PatientDto>(bytes)!;

// Option 2: MessagePack (faster, smaller payload, requires attribute annotation)
// Install: MessagePack
var bytes = MessagePackSerializer.Serialize(dto);
var dto   = MessagePackSerializer.Deserialize<PatientDto>(bytes);

// Create a typed cache wrapper to avoid serialization code in every service
public sealed class TypedCache<T>
{
    private readonly IDistributedCache _cache;

    public async Task<T?> GetAsync(string key, CancellationToken ct)
    {
        var bytes = await _cache.GetAsync(key, ct);
        return bytes is null ? default : JsonSerializer.Deserialize<T>(bytes);
    }

    public async Task SetAsync(string key, T value,
        DistributedCacheEntryOptions opts, CancellationToken ct)
    {
        var bytes = JsonSerializer.SerializeToUtf8Bytes(value);
        await _cache.SetAsync(key, bytes, opts, ct);
    }

    public Task RemoveAsync(string key, CancellationToken ct)
        => _cache.RemoveAsync(key, ct);
}

Cache-Aside Pattern

C#
// Standard pattern: check cache → miss → load from DB → populate cache → return
public async Task<List<DrugDto>> GetFormularyAsync(Guid hospitalId, CancellationToken ct)
{
    var key   = $"formulary:{hospitalId}";
    var bytes = await _cache.GetAsync(key, ct);

    if (bytes is not null)
    {
        _logger.LogDebug("Cache hit: {Key}", key);
        return JsonSerializer.Deserialize<List<DrugDto>>(bytes)!;
    }

    _logger.LogDebug("Cache miss: {Key}", key);
    var drugs = await _repo.GetFormularyAsync(hospitalId, ct);
    var dtos  = drugs.Select(d => d.ToDto()).ToList();

    await _cache.SetAsync(key,
        JsonSerializer.SerializeToUtf8Bytes(dtos),
        new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(4)
        }, ct);

    return dtos;
}

Cache Invalidation Patterns

C#
// Single-entity invalidation
await _cache.RemoveAsync($"patient:{id}", ct);

// Pattern-based invalidation requires StackExchange.Redis directly
// (IDistributedCache does not support key pattern deletion)
var redis   = ConnectionMultiplexer.Connect(connectionString);
var server  = redis.GetServer(redis.GetEndPoints().First());
var keys    = server.Keys(pattern: "patient:*").ToArray();
foreach (var key in keys)
    await redis.GetDatabase().KeyDeleteAsync(key);

// Tag-based invalidation (requires HybridCache or custom implementation)
// Store tags with entries, use a tag index to find related keys

Production issue I've seen: A team updated the drug formulary in the admin portal but the change did not appear for nurses until the 4-hour cache TTL expired. They had no cache invalidation on formulary save. Adding InvalidateAsync("formulary:{hospitalId}") in the formulary update handler made changes visible immediately.


Redis Connection Resilience

C#
// StackExchange.Redis reconnects automatically, but operations during
// disconnection fail. Handle gracefully:
public async Task<T?> GetWithFallbackAsync<T>(
    string key, Func<Task<T>> fallback, CancellationToken ct) where T : class
{
    try
    {
        var bytes = await _cache.GetAsync(key, ct);
        if (bytes is not null)
            return JsonSerializer.Deserialize<T>(bytes);
    }
    catch (RedisConnectionException ex)
    {
        _logger.LogWarning(ex, "Redis unavailable, bypassing cache for {Key}", key);
        return await fallback();  // fall through to DB
    }

    var value = await fallback();
    if (value is not null)
    {
        try
        {
            await _cache.SetAsync(key,
                JsonSerializer.SerializeToUtf8Bytes(value),
                new DistributedCacheEntryOptions
                    { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) }, ct);
        }
        catch (RedisConnectionException) { /* best-effort populate */ }
    }
    return value;
}

Red Flag / Green Answer

Red Flag: "We use IDistributedCache but our keys are just the entity name like 'patients'."

Every save invalidates the entire collection. With 50,000 patients, every patient update causes a miss that reloads 50,000 records. Use entity-specific keys: patient:{id}, formulary:{hospitalId}, ward:{wardId}.

Green Answer:

Granular keys by entity ID. Collections use separate keys with shorter TTLs. Invalidate only the specific entry when an entity changes.


Key Takeaway

IDistributedCache with Redis is the shared cache that survives API restarts and is consistent across all instances. The pattern: bytes in, bytes out — always serialize. Use the cache-aside pattern: check cache → miss → load → populate. Invalidate on mutation — immediate consistency matters in clinical systems. Add Redis connection resilience to fall back to the database when Redis is unavailable.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.