Back to CourseIntermediate6 min read
Azure /

Azure Cache for Redis

Add Azure Cache for Redis to reduce database load, choose a tier, and avoid the caching mistakes that cause outages.

Bishal Biswas

Bishal Biswas

Self Learner

Published on: October 15, 2026
Last updated: September 7, 2026
Intermediate Topic

What it is for

An in memory key value store. Reads that would hit a database in tens of milliseconds hit Redis in under one.

Cache aside     check Redis, fall back to the database, store the result
Session state   shared across instances, so any instance serves any user
Distributed lock  one worker does a job, not all of them
Rate limiting   counters with expiry
Pub sub         lightweight fan out, not a durable message broker

Tiers

az redis create \
  --name redis-shop --resource-group rg-shop-prod-uks \
  --location uksouth --sku Standard --vm-size c1 \
  --minimum-tls-version 1.2 --redis-version 6
Basic     one node, NO SLA, no replication. Development only.
Standard  two nodes with replication, 99.9 percent SLA. Most production.
Premium   clustering, persistence, vnet injection, zone redundancy, geo replication
Enterprise  Redis Enterprise modules, active geo replication, higher SLA

Basic has no replication. A node restart loses everything and the cache is unavailable. That is fine for development and not for production, even for a cache.

Cache aside

public async Task<Product?> GetProductAsync(int id)
{
    var key = $"product:{id}";
    var cached = await _cache.StringGetAsync(key);

    if (cached.HasValue)
    {
        return JsonSerializer.Deserialize<Product>(cached!);
    }

    var product = await _repository.GetAsync(id);
    if (product is null)
    {
        return null;
    }

    await _cache.StringSetAsync(
        key,
        JsonSerializer.Serialize(product),
        TimeSpan.FromMinutes(10));

    return product;
}

Always set an expiry. A cache without expiry is a second database with no backup and no consistency guarantee, and it fills up.

Ten minutes, an hour, a day: the right value depends on how stale the data may be. Something is almost always better than nothing.

The connection must be shared

builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
{
    var options = ConfigurationOptions.Parse(
        builder.Configuration.GetConnectionString("Redis")!);
    options.AbortOnConnectFail = false;
    options.ConnectRetry = 3;
    options.ConnectTimeout = 5000;
    return ConnectionMultiplexer.Connect(options);
});

builder.Services.AddSingleton<IDatabase>(sp =>
    sp.GetRequiredService<IConnectionMultiplexer>().GetDatabase());

ConnectionMultiplexer is expensive to create and designed to be shared. Creating one per request exhausts connections and produces timeouts that look like Redis being slow.

This is the single most common mistake with Redis in .NET, and it is a one line fix.

AbortOnConnectFail = false lets the application start when Redis is briefly unavailable and reconnect later, rather than failing at startup.

Connecting with Entra ID

az redis access-policy-assignment create \
  --name redis-shop --resource-group rg-shop-prod-uks \
  --policy-name "Data Contributor" \
  --object-id "$PRINCIPAL_ID" --object-id-alias shop-api \
  --access-policy-assignment-name shop-api-assignment
var configuration = await ConfigurationOptions.Parse("redis-shop.redis.cache.windows.net:6380")
    .ConfigureForAzureWithTokenCredentialAsync(new DefaultAzureCredential());
configuration.Ssl = true;

var multiplexer = await ConnectionMultiplexer.ConnectAsync(configuration);

Entra ID authentication removes the access key. The helper handles token refresh, which matters because tokens expire and a long lived connection needs re-authentication.

Disable access keys once everything uses identity:

az redis update --name redis-shop --resource-group rg-shop-prod-uks \
  --set disableAccessKeyAuthentication=true

Distributed cache in ASP.NET Core

builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "shop:";
});

// output caching backed by Redis
builder.Services.AddOutputCache();
public class ProductService(IDistributedCache cache, IProductRepository repository)
{
    public async Task<Product?> GetAsync(int id)
    {
        var key = $"product:{id}";
        var bytes = await cache.GetAsync(key);
        if (bytes is not null)
        {
            return JsonSerializer.Deserialize<Product>(bytes);
        }

        var product = await repository.GetAsync(id);
        if (product is null) return null;

        await cache.SetAsync(key, JsonSerializer.SerializeToUtf8Bytes(product),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
                SlidingExpiration = TimeSpan.FromMinutes(2)
            });

        return product;
    }
}

Set an absolute expiration alongside any sliding expiration. Sliding alone means a frequently read key never expires and never refreshes.

Cache stampede

When a popular key expires, every request misses simultaneously and they all hit the database at once. A cache that was protecting the database now amplifies load onto it.

private static readonly SemaphoreSlim _lock = new(1, 1);

public async Task<Product?> GetProductAsync(int id)
{
    var key = $"product:{id}";
    var cached = await _cache.StringGetAsync(key);
    if (cached.HasValue) return JsonSerializer.Deserialize<Product>(cached!);

    await _lock.WaitAsync();
    try
    {
        // check again: another thread may have populated it while we waited
        cached = await _cache.StringGetAsync(key);
        if (cached.HasValue) return JsonSerializer.Deserialize<Product>(cached!);

        var product = await _repository.GetAsync(id);
        if (product is not null)
        {
            // jitter, so keys populated together do not expire together
            var ttl = TimeSpan.FromMinutes(10)
                + TimeSpan.FromSeconds(Random.Shared.Next(0, 120));
            await _cache.StringSetAsync(key,
                JsonSerializer.Serialize(product), ttl);
        }
        return product;
    }
    finally
    {
        _lock.Release();
    }
}

Two techniques, both worth having: a lock so only one caller refreshes, and jitter on the expiry so keys populated at the same time do not expire at the same time.

HybridCache in .NET 9 and later does both, plus an in process layer in front of Redis, and is worth using instead of writing this by hand.

Invalidation

public async Task UpdateProductAsync(Product product)
{
    await _repository.UpdateAsync(product);
    await _cache.KeyDeleteAsync($"product:{product.Id}");
    await _cache.KeyDeleteAsync("products:featured");
}

Delete rather than update. Updating the cache means a window where the database write succeeded and the cache write did not, and the two disagree indefinitely.

Deleting means the next read repopulates from the source of truth. A brief miss is much better than lasting inconsistency.

Never use KEYS

// NEVER in production: blocks the server while it scans every key
// var keys = server.Keys(pattern: "product:*");

// scan iterates in batches without blocking
foreach (var key in server.Keys(pattern: "product:*", pageSize: 250))
{
    await _cache.KeyDeleteAsync(key);
}

Redis is single threaded. KEYS on a large database blocks every other operation until it finishes, which on a big cache means seconds of total unavailability.

The same applies to FLUSHALL and to very large MGET calls. Anything O(n) over the whole keyspace is dangerous on a busy instance.

Memory policy

az redis update --name redis-shop --resource-group rg-shop-prod-uks \
  --set "redisConfiguration.maxmemory-policy=allkeys-lru"
volatile-lru   evict least recently used, only keys WITH an expiry. Default.
allkeys-lru    evict least recently used, any key. Right for a pure cache.
noeviction     return errors when full. Right when Redis holds session state.

The default volatile-lru cannot evict keys with no expiry, so an instance full of keys without expiry starts rejecting writes. That combination causes real outages, and setting an expiry on everything avoids it.

Premium features

az redis create --name redis-shop --resource-group rg-shop-prod-uks \
  --sku Premium --vm-size P1 --shard-count 3 \
  --redis-configuration '{"maxmemory-policy":"allkeys-lru"}' \
  --zones 1 2 3
Clustering        shard across nodes for more memory and throughput
Persistence       RDB snapshots or AOF, so a restart does not lose everything
Vnet injection    no public endpoint at all
Zone redundancy   survives a zone failure
Geo replication   a linked secondary in another region

Clustering changes behaviour: multi key operations only work when the keys are in the same slot, which needs hash tags like {customer:42}:orders.

Monitoring

az monitor metrics list \
  --resource /subscriptions/.../Redis/redis-shop \
  --metric cachehits cachemisses usedmemorypercentage serverLoad connectedclients evictedkeys \
  --interval PT5M --output table
Hit ratio below 80 percent   the cache is not earning its cost
usedmemorypercentage high    approaching eviction
serverLoad above 80          Redis itself is the bottleneck
evictedkeys rising           keys are being pushed out early
connectedclients climbing    probably a multiplexer per request

A rising connected client count with no traffic increase is the signature of the shared connection mistake.

Quick recap

  • Basic tier has no replication, so a restart loses everything and it is not for production.
  • Always set an expiry, or the cache becomes a second database that fills up.
  • Share one ConnectionMultiplexer, since creating one per request is the most common Redis mistake.
  • Guard against stampedes with a lock and jitter, or use HybridCache.
  • Invalidate by deleting, not updating, so a failed write cannot leave lasting inconsistency.
  • Never run KEYS in production, because Redis is single threaded and it blocks everything.
  • The default eviction policy cannot evict keys without expiry, which causes write failures.
deploy.sh

// TIERS: Basic has NO REPLICATION, so a node restart loses everything and
// the cache is unavailable. Fine for development, not for production.
// Standard is two nodes with a 99.9% SLA. Premium adds clustering,
// persistence, vnet injection, zone redundancy and geo replication.
//
// THE MOST COMMON MISTAKE IN .NET, AND A ONE LINE FIX. ConnectionMultiplexer
// is expensive and DESIGNED TO BE SHARED. One per request exhausts
// connections and produces timeouts that look like Redis being slow.
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
{
    var options = ConfigurationOptions.Parse(connectionString);
    options.AbortOnConnectFail = false;  // start even if Redis is briefly down
    options.ConnectRetry = 3;
    return ConnectionMultiplexer.Connect(options);
});
//
// CACHE ASIDE. ALWAYS SET AN EXPIRY: a cache without one is a second
// database with no backup and no consistency guarantee, and it fills up.
public async Task<Product?> GetProductAsync(int id)
{
    var key = $"product:{id}";
    var cached = await _cache.StringGetAsync(key);
    if (cached.HasValue) return JsonSerializer.Deserialize<Product>(cached!);
//
    // CACHE STAMPEDE: when a popular key expires EVERY request misses at
    // once, and the cache that was protecting the database now amplifies
    // load onto it. A lock means only one caller refreshes.
    await _lock.WaitAsync();
    try
    {
        cached = await _cache.StringGetAsync(key);   // re-check after waiting
        if (cached.HasValue) return JsonSerializer.Deserialize<Product>(cached!);
//
        var product = await _repository.GetAsync(id);
        // JITTER, so keys populated together do not EXPIRE together:
        var ttl = TimeSpan.FromMinutes(10)
                + TimeSpan.FromSeconds(Random.Shared.Next(0, 120));
        await _cache.StringSetAsync(key, JsonSerializer.Serialize(product), ttl);
        return product;
    }
    finally { _lock.Release(); }
}
// HybridCache in .NET 9+ does both of those, plus an in process layer in
// front of Redis. Prefer it to writing this by hand.
//
// INVALIDATE BY DELETING, NOT UPDATING. Updating leaves a window where the
// database write succeeded and the cache write did not, and they then
// disagree indefinitely. A brief miss beats lasting inconsistency.
await _repository.UpdateAsync(product);
await _cache.KeyDeleteAsync($"product:{product.Id}");
//
// NEVER RUN KEYS IN PRODUCTION. Redis is SINGLE THREADED, so KEYS on a
// large database blocks EVERY other operation until it finishes. Use SCAN:
foreach (var key in server.Keys(pattern: "product:*", pageSize: 250)) { }
//
// EVICTION POLICY. The default volatile-lru CANNOT EVICT KEYS WITH NO
// EXPIRY, so an instance full of them starts REJECTING WRITES. Real
// outages come from this.
//   volatile-lru  default, only keys with an expiry
//   allkeys-lru   right for a pure cache
//   noeviction    right when Redis holds session state
//
// WATCH: hit ratio under 80% means it is not earning its cost. Rising
// connectedclients with no traffic increase is the shared connection bug.

Was this explanation helpful?

Your feedback helps us improve the content for everyone.