Redis is in almost every serious .NET SaaS product's stack. It's fast (sub-millisecond reads from memory), flexible (strings, hashes, sorted sets, streams, pub/sub), and mature enough that Azure Cache for Redis is a production-ready managed service with 99.9% SLA.
But "add Redis" is not a caching strategy. The wrong caching pattern can cause stale data bugs, tenant data leakage across a shared cache, or thundering herd problems that make your scaling problem worse, not better. This post covers the patterns that actually work in production .NET multi-tenant SaaS.
StackExchange.Redis Setup for .NET
The standard .NET Redis client is StackExchange.Redis. For ASP.NET Core, use the Microsoft.Extensions.Caching.StackExchangeRedis package to integrate with the IDistributedCache abstraction, or use StackExchange.Redis directly for more control.
// Program.cs — production Redis setup
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "MyApp:"; // key prefix for all IDistributedCache keys
});
// For direct IDatabase access (more control)
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
ConnectionMultiplexer.Connect(new ConfigurationOptions
{
EndPoints = { builder.Configuration["Redis:Endpoint"]! },
Password = builder.Configuration["Redis:Password"],
Ssl = true,
AbortOnConnectFail = false, // retry on startup instead of crashing
ConnectRetry = 5,
ReconnectRetryPolicy = new LinearRetry(1000),
DefaultDatabase = 0
}));
builder.Services.AddScoped<IDatabase>(sp =>
sp.GetRequiredService<IConnectionMultiplexer>().GetDatabase());
Pattern 1: Cache-Aside (Lazy Loading)
Cache-Aside
Application checks the cache first. On miss, loads from database, writes to cache, returns data. On next read: cache hit. Cache expires on a TTL and is repopulated on next miss.
// Generic cache-aside service for .NET
public class CacheService(IDatabase redis, ILogger<CacheService> logger)
{
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
public async Task<T?> GetOrSetAsync<T>(
string key,
Func<Task<T?>> factory,
TimeSpan? expiry = null) where T : class
{
var cached = await redis.StringGetAsync(key);
if (cached.HasValue)
{
return JsonSerializer.Deserialize<T>(cached!, _jsonOptions);
}
var value = await factory();
if (value is not null)
{
var serialised = JsonSerializer.Serialize(value, _jsonOptions);
await redis.StringSetAsync(key, serialised, expiry ?? TimeSpan.FromMinutes(15));
}
return value;
}
public async Task InvalidateAsync(string key) =>
await redis.KeyDeleteAsync(key);
}
// Usage in a service
var product = await cache.GetOrSetAsync(
$"product:{productId}",
() => dbContext.Products.FindAsync(productId).AsTask(),
TimeSpan.FromMinutes(30));
Critical: Tenant Key Isolation
In a shared-cache multi-tenant setup, tenant A must never read tenant B's cached data. The simplest and most reliable pattern: prefix every cache key with the tenant ID.
This is a security issue, not just a bug. If tenant keys are not isolated, a cache hit for tenant A can serve tenant B's data. In financial, healthcare, or enterprise SaaS contexts, this is a data breach. Always prefix keys.
// Tenant-aware cache service — wraps CacheService with tenant prefix
public class TenantCacheService(
CacheService cache,
ICurrentTenantProvider tenantProvider)
{
private string Prefix(string key)
{
var tenantId = tenantProvider.GetTenantId()
?? throw new InvalidOperationException("No tenant context");
return $"t:{tenantId}:{key}"; // e.g. "t:acme-corp:product:42"
}
public Task<T?> GetOrSetAsync<T>(
string key, Func<Task<T?>> factory, TimeSpan? expiry = null) where T : class
=> cache.GetOrSetAsync(Prefix(key), factory, expiry);
public Task InvalidateAsync(string key)
=> cache.InvalidateAsync(Prefix(key));
// Invalidate ALL keys for this tenant (e.g., on tenant config change)
public async Task InvalidateTenantAsync()
{
var tenantId = tenantProvider.GetTenantId()!;
var server = redis.Multiplexer.GetServer(redis.Multiplexer.GetEndPoints().First());
var keys = server.Keys(pattern: $"t:{tenantId}:*").ToArray();
if (keys.Length > 0)
await redis.KeyDeleteAsync(keys);
}
}
Pattern 2: Write-Through
Write-Through
On every write to the database, also write to the cache. No cache miss possible for recently-written data. Slightly higher write latency (two writes instead of one) but eliminates stale reads.
// Write-through example for user preferences
public class UserPreferencesService(AppDbContext db, TenantCacheService cache)
{
private static string CacheKey(int userId) => $"user:prefs:{userId}";
public async Task<UserPreferences?> GetAsync(int userId)
=> await cache.GetOrSetAsync(
CacheKey(userId),
() => db.UserPreferences.FindAsync(userId).AsTask(),
TimeSpan.FromHours(1));
public async Task UpdateAsync(UserPreferences prefs)
{
db.UserPreferences.Update(prefs);
await db.SaveChangesAsync();
// Write-through: update cache immediately, don't wait for TTL expiry
await cache.InvalidateAsync(CacheKey(prefs.UserId));
// Re-populate (or just invalidate and let next read repopulate)
await cache.GetOrSetAsync(
CacheKey(prefs.UserId),
() => Task.FromResult<UserPreferences?>(prefs),
TimeSpan.FromHours(1));
}
}
Distributed Sessions with Redis
For multi-instance .NET deployments (Kubernetes, Azure App Service scale-out), in-memory session state doesn't survive a request being routed to a different instance. Redis-backed distributed session solves this.
// Program.cs — Redis-backed session
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "Session:";
});
builder.Services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromMinutes(30);
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Strict;
});
// app.UseSession() before app.UseAuthorization()
// Usage in a controller
HttpContext.Session.SetString("CurrentTenantPlan", plan.ToString());
var plan = HttpContext.Session.GetString("CurrentTenantPlan");
Pub/Sub: Real-Time Features Without a Message Broker
Redis Pub/Sub lets you broadcast messages across all instances of your .NET app. Common use cases: live dashboard updates, cache invalidation across instances, real-time notifications without full SignalR infrastructure.
// Publisher — send a message when an order status changes
public class OrderService(IConnectionMultiplexer redis, AppDbContext db)
{
private readonly ISubscriber _sub = redis.GetSubscriber();
public async Task UpdateOrderStatusAsync(int orderId, OrderStatus status)
{
await db.Orders
.Where(o => o.Id == orderId)
.ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, status));
// Publish to all subscribed instances
await _sub.PublishAsync(
RedisChannel.Literal("order-status-changed"),
JsonSerializer.Serialize(new { OrderId = orderId, Status = status }));
}
}
// Subscriber — register in IHostedService
public class OrderStatusSubscriber(IConnectionMultiplexer redis) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var sub = redis.GetSubscriber();
await sub.SubscribeAsync(
RedisChannel.Literal("order-status-changed"),
(channel, message) =>
{
var payload = JsonSerializer.Deserialize<OrderStatusChanged>(message!);
// Update real-time dashboard, push SignalR notification, etc.
logger.LogInformation("Order {Id} → {Status}", payload!.OrderId, payload.Status);
});
await Task.Delay(Timeout.Infinite, stoppingToken);
}
}
Thundering Herd Prevention
When a cached item expires, all simultaneous requests that miss the cache hit the database at the same time. For high-traffic items (dashboard summary, shared config), this thundering herd can spike database load exactly when you least want it.
Fix: Cache locking with Redis SETNX. On cache miss, one request acquires a lock and populates the cache. Other requests wait briefly (or serve stale data) until the lock-holder finishes. StackExchange.Redis supports this with LockTakeAsync.
// Thundering herd prevention with Redis lock
public async Task<DashboardSummary?> GetDashboardAsync(string tenantId)
{
var cacheKey = $"t:{tenantId}:dashboard:summary";
var lockKey = $"{cacheKey}:lock";
// Try cache first
var cached = await redis.StringGetAsync(cacheKey);
if (cached.HasValue)
return JsonSerializer.Deserialize<DashboardSummary>(cached!);
// Try to acquire lock (expires in 10 seconds in case of failure)
var lockValue = Guid.NewGuid().ToString();
var lockAcquired = await redis.StringSetAsync(
lockKey, lockValue, TimeSpan.FromSeconds(10), When.NotExists);
if (!lockAcquired)
{
// Another instance is populating — wait briefly and retry
await Task.Delay(100);
var retryCache = await redis.StringGetAsync(cacheKey);
return retryCache.HasValue
? JsonSerializer.Deserialize<DashboardSummary>(retryCache!)
: null; // or return stale data from a secondary cache
}
try
{
var summary = await db.BuildDashboardSummaryAsync(tenantId);
await redis.StringSetAsync(
cacheKey,
JsonSerializer.Serialize(summary),
TimeSpan.FromMinutes(5));
return summary;
}
finally
{
// Release lock only if we still own it
var script = LuaScript.Prepare(
"if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end");
await redis.ScriptEvaluateAsync(script, new RedisKey[] { lockKey }, new RedisValue[] { lockValue });
}
}
When Not to Use Redis
| Scenario | Use Redis? | Reason |
|---|---|---|
| Multi-instance deployment (Kubernetes, Azure scale-out) | ✅ Yes | In-memory cache is per-instance; Redis is shared |
| Single-instance app (internal tool, dev environment) | ⚠️ Optional | IMemoryCache is simpler and faster for single instance |
| Session state across multiple app servers | ✅ Yes | Session must survive any instance receiving the request |
| Very short-lived computed values (<10ms to compute) | ❌ No | Redis round-trip (1–2ms) exceeds compute time; no benefit |
| Highly personalised data (unique per user, per request) | ❌ No | Cache hit rate will be near 0%; waste of memory |
| Real-time pub/sub (small scale) | ✅ Yes | Redis pub/sub is simpler than a full message broker for <10K messages/sec |
| Rate limiting (API throttle counters) | ✅ Yes | Redis INCR + EXPIRE is atomic; perfect for sliding window counters |
Azure Cache for Redis: Production Configuration
For .NET SaaS on Azure, Azure Cache for Redis is the managed option. Key configuration decisions:
- SKU: Standard C1 (1 GB, no clustering, with replication) is the minimum for production. Basic has no replica — one node failure = cache outage.
- Private Endpoint: Always use private endpoint to prevent public internet exposure. Block public access.
- TLS: Enforce TLS 1.2 minimum. Set
Ssl=truein your connection string. - Eviction policy:
allkeys-lrufor a pure cache.volatile-lruif some keys must never be evicted (rate limit counters with no TTL). - Geo-replication: Premium tier only. Needed if your SaaS serves multiple Azure regions.
// Connection string for Azure Cache for Redis (secure)
"Redis": "myapp.redis.cache.windows.net:6380,password=***,ssl=True,abortConnect=False,connectRetry=5"
Summary
Redis in a multi-tenant .NET SaaS is not an optional optimisation — at meaningful scale, it's the difference between an API that responds in 50ms and one that hits the database on every request and falls over under load. The key points:
- Always prefix keys with tenant ID. Tenant data leakage from a shared cache is a security incident.
- Cache-aside for read-heavy reference data. Write-through for data that must be immediately consistent after a write.
- Distributed session is mandatory for multi-instance deployments — in-memory session breaks with scale-out.
- Pub/sub for simple real-time coordination across instances without a full message broker.
- Thundering herd prevention with Redis locks on high-traffic, expensive-to-compute cache entries.
- Azure Cache for Redis Standard tier minimum for production. Basic tier has no replica and will cause outages.