The Hard Part of Caching Is Never the Cache Hit
Adding Redis to a .NET API is an afternoon's work. IDistributedCache, a connection string, a serialiser, a TTL — done. The cache fills, latency drops, the dashboard looks good. Then a product price changes. The old value sits in Redis. Users see stale data. The fix gets escalated. The post-mortem conclusion: "we need a cache invalidation strategy."
Cache invalidation is hard not because the mechanics are complex but because the problem has three distinct shapes, and each shape requires a different solution. Stale data after a single entity update is a different problem from stale data across a set of related keys, which is a different problem again from stale data in a service that had no direct involvement in the mutation. This article covers all three: tag-based eviction for grouped key invalidation, version-token key design for safe rotation without explicit deletes, and event-driven purge via Redis pub/sub for cross-service invalidation. Each section gives you the complete C# implementation — not pseudocode.
Why KeyDelete on Its Own Is Not a Strategy
The instinctive approach to invalidation is to call KeyDeleteAsync("product:42") immediately after updating the product. This works precisely until it doesn't. The failure modes are well-documented and all occur in production: the delete call throws or times out and the stale key remains; a second cache writer races the delete and immediately re-inserts the old value before the database write commits; related keys — product-list:category:electronics, homepage:featured-products — are not deleted because the call site does not know they exist; and in a multi-service architecture, the service that mutates the product does not know which other services have cached a representation of it.
// ──────────────────────────────────────────────────────────────────────────────
// PITFALL 1 The delete-on-write race condition
// ──────────────────────────────────────────────────────────────────────────────
// Timeline:
// T0 Writer A updates product 42 in the database
// T1 Writer A calls KeyDeleteAsync("product:42") ← delete issued
// T2 Reader calls GetAsync("product:42") → cache miss
// T3 Reader fetches OLD value from DB (replication lag!) → re-populates cache
// T4 Writer A's database write becomes visible
// Result: cache now contains the OLD price. The delete accomplished nothing.
// The root cause is that delete-then-repopulate has no coordination with
// concurrent readers. The solution is not faster deletion — it is key versioning,
// which makes old keys unreachable without deleting them.
// ──────────────────────────────────────────────────────────────────────────────
// PITFALL 2 Partial invalidation — you deleted the leaf but not the branch
// ──────────────────────────────────────────────────────────────────────────────
public async Task UpdateProductPriceAsync(int productId, decimal newPrice)
{
await _dbContext.Products
.Where(p => p.Id == productId)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, newPrice));
// This invalidates the product detail key. But does NOT invalidate:
// - "products:category:electronics" (list that includes this product)
// - "homepage:featured" (featured products widget)
// - "search:results:laptop-under-1000" (search result that included this product)
// - "user:42:recommendations" (personalised list containing this product)
await _cache.RemoveAsync($"product:{productId}");
// Each of those keys now serves stale data until their TTL expires.
// The call site cannot enumerate them — it does not know they exist.
// This is the problem tag-based eviction solves.
}
// ──────────────────────────────────────────────────────────────────────────────
// PITFALL 3 Using KEYS or SCAN in production to find related keys
// ──────────────────────────────────────────────────────────────────────────────
// DO NOT do this in production:
var server = _redis.GetServer(_redis.GetEndPoints().First());
await foreach (var key in server.KeysAsync(pattern: "product:*"))
{
await _db.KeyDeleteAsync(key);
}
// KEYS blocks Redis for the entire scan. On millions of keys: seconds of pause.
// SCAN is non-blocking but NOT atomic — keys inserted during the scan are missed.
// Neither is a reliable invalidation strategy.
// The correct approach is to maintain an explicit key index per tag. See Section 3.
The three pitfalls above share a root cause: the invalidation logic is decoupled from the key organisation strategy. The solution is not to make the delete calls faster or more comprehensive — it is to design keys and key relationships so that invalidation is a deterministic, atomic, indexed operation rather than a search problem.
Tag-Based Eviction: Atomic Group Invalidation with Redis Sets
Tag-based eviction solves the partial-invalidation problem by maintaining an explicit index of which cache keys belong to which logical group. When you write product:42 to the cache, you also add product:42 to the Redis Set keyed as tag:product:42. Every other cache key that contains data derived from product 42 — list pages, search results, featured widgets — is registered in the same set at write time. When product 42 changes, a single call purges the entire set atomically using a Lua script. The call site does not need to enumerate related keys at invalidation time; it registered them at write time, which is the only moment when the derivation relationship is certain.
using StackExchange.Redis;
using System.Text.Json;
// ──────────────────────────────────────────────────────────────────────────────
// The Lua script for atomic tag purge.
// Runs entirely on the Redis server — no interleaving is possible.
// KEYS[1] = the tag set key (e.g. "tag:product:42")
// Returns the number of keys deleted (including the tag set itself).
// ──────────────────────────────────────────────────────────────────────────────
public static class RedisScripts
{
public const string PurgeTag = @"
local members = redis.call('SMEMBERS', KEYS[1])
local deleted = 0
for _, key in ipairs(members) do
deleted = deleted + redis.call('DEL', key)
end
redis.call('DEL', KEYS[1])
return deleted
";
}
public sealed class TaggedCacheService
{
private readonly IDatabase _db;
private readonly TimeSpan _defaultTtl = TimeSpan.FromMinutes(10);
public TaggedCacheService(IConnectionMultiplexer redis)
=> _db = redis.GetDatabase();
// ──────────────────────────────────────────────────────────────────────────
// SetAsync — write a value AND register it under one or more tags.
// Call this instead of IDistributedCache.SetAsync whenever the cached
// value is derived from a tagged entity.
// ──────────────────────────────────────────────────────────────────────────
public async Task SetAsync(
string cacheKey,
T value,
string[] tags,
TimeSpan? ttl = null)
{
var json = JsonSerializer.Serialize(value);
var duration = ttl ?? _defaultTtl;
// Pipeline: SET the value key, then SADD it to every tag set.
// We use a pipeline (not a transaction) because the SET and SADD
// do not need to be atomic relative to each other — only the purge
// (which uses Lua) needs atomicity.
var batch = _db.CreateBatch();
var setTask = batch.StringSetAsync(cacheKey, json, duration);
// Register this cache key under each of its tags.
// Tag sets themselves carry a longer TTL to outlive their member keys.
var tagTasks = tags.Select(tag =>
batch.SetAddAsync(TagKey(tag), cacheKey));
var tagExpireTasks = tags.Select(tag =>
batch.KeyExpireAsync(TagKey(tag), duration.Add(TimeSpan.FromMinutes(5))));
batch.Execute();
await setTask;
await Task.WhenAll(tagTasks);
await Task.WhenAll(tagExpireTasks);
}
// ──────────────────────────────────────────────────────────────────────────
// GetAsync — standard cache read, no tag involvement.
// ──────────────────────────────────────────────────────────────────────────
public async Task<T?> GetAsync<T>(string cacheKey)
{
var value = await _db.StringGetAsync(cacheKey);
if (value.IsNullOrEmpty) return default;
return JsonSerializer.Deserialize<T>(value!);
}
// ──────────────────────────────────────────────────────────────────────────
// PurgeTagAsync — atomically delete all keys registered under a tag.
// This is the invalidation entry point. Call it from your write path.
// ──────────────────────────────────────────────────────────────────────────
public async Task<long> PurgeTagAsync(string tag)
{
var result = await _db.ScriptEvaluateAsync(
RedisScripts.PurgeTag,
keys: new RedisKey[] { TagKey(tag) }
);
return (long)result;
}
// Tag key convention: "tag:{logical-tag-name}"
private static string TagKey(string tag) => $"tag:{tag}";
}
// ──────────────────────────────────────────────────────────────────────────────
// Usage: write path — register multiple tags at cache write time.
// The ProductController does not need to know about categories or search
// results. The caching layer knows, because it registered the tags when
// it wrote those derived cache entries.
// ──────────────────────────────────────────────────────────────────────────────
public class ProductCacheWriter
{
private readonly TaggedCacheService _cache;
public async Task CacheProductDetailAsync(Product product)
{
// Register this cache entry under the product tag AND the category tag.
// Either tag can trigger purge independently.
await _cache.SetAsync(
cacheKey: $"product:{product.Id}",
value: product,
tags: new[] { $"product:{product.Id}", $"category:{product.CategoryId}" }
);
}
public async Task CacheProductListAsync(int categoryId, List<ProductSummary> items)
{
// A list page is derived from the category — register under the category tag.
await _cache.SetAsync(
cacheKey: $"products:category:{categoryId}",
value: items,
tags: new[] { $"category:{categoryId}" },
ttl: TimeSpan.FromMinutes(5)
);
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Invalidation: one call purges every key tagged with "product:42" — including
// derived list pages and related widgets registered at write time.
// ──────────────────────────────────────────────────────────────────────────────
public class ProductWriteService
{
private readonly TaggedCacheService _cache;
private readonly AppDbContext _db;
public async Task UpdateProductAsync(UpdateProductCommand cmd)
{
await _db.Products
.Where(p => p.Id == cmd.ProductId)
.ExecuteUpdateAsync(s => s
.SetProperty(p => p.Name, cmd.Name)
.SetProperty(p => p.Price, cmd.Price));
// One call. Purges product:42, products:category:N, and any other
// key registered under the product tag at write time.
long purged = await _cache.PurgeTagAsync($"product:{cmd.ProductId}");
// purged = number of Redis keys deleted including the tag set itself
}
}
The tag set is the index that makes invalidation O(tag members) rather than O(keyspace). The Lua script is the atomicity guarantee. Both components are required — without the Lua script, concurrent writers can insert new members into the tag set between your SMEMBERS read and your DEL calls, leaving recently-written keys permanently stale until TTL expiry.
Versioned Keys: Safe Invalidation Without Explicit Deletes
Version-token key design takes a different philosophy: instead of deleting stale keys, make them unreachable by changing the key name. Every cache key is prefixed with a version token stored separately in Redis — v3:product:42 rather than product:42. When the product changes, you increment the version token from v3 to v4. The next cache read constructs the key v4:product:42, gets a miss, fetches from the database, and populates the new versioned key. The old key v3:product:42 is now unreachable — no reader will ever construct that key again — and it will expire naturally when its TTL passes. No delete call required, no Lua script, no race condition.
using StackExchange.Redis;
using System.Text.Json;
// ──────────────────────────────────────────────────────────────────────────────
// Version tokens are stored as simple Redis strings.
// Key: "ver:{scope}" Value: current version integer (as string)
// The scope can be an entity ID ("product:42"), a category ("category:electronics"),
// or a global scope ("site") for coarse-grained invalidation.
// ──────────────────────────────────────────────────────────────────────────────
public sealed class VersionedCacheService
{
private readonly IDatabase _db;
private readonly TimeSpan _defaultTtl = TimeSpan.FromMinutes(10);
private readonly TimeSpan _versionTokenTtl = TimeSpan.FromHours(24);
public VersionedCacheService(IConnectionMultiplexer redis)
=> _db = redis.GetDatabase();
// ──────────────────────────────────────────────────────────────────────────
// GetVersionedKeyAsync — builds the current versioned key for a given
// logical key and version scope. If no version token exists yet, creates
// one starting at 1.
// ──────────────────────────────────────────────────────────────────────────
private async Task<string> GetVersionedKeyAsync(string logicalKey, string versionScope)
{
// INCR is atomic and creates the key with value 1 if it does not exist.
// We only read the version here — use IncrementVersionAsync to bump it.
var versionToken = await _db.StringGetAsync(VersionKey(versionScope));
long version = versionToken.HasValue
? (long)versionToken
: await InitVersionAsync(versionScope);
return $"v{version}:{logicalKey}";
}
private async Task<long> InitVersionAsync(string scope)
{
// SET NX: only sets if key does not exist. Prevents overwriting a version
// that was written between our GET miss and this SET call.
await _db.StringSetAsync(
VersionKey(scope), "1",
_versionTokenTtl,
When.NotExists
);
// Re-read after the SET NX to get the authoritative value.
return (long)await _db.StringGetAsync(VersionKey(scope));
}
// ──────────────────────────────────────────────────────────────────────────
// GetAsync — cache read using the current versioned key.
// A miss here either means the value was never cached OR the version was
// incremented (old key is now unreachable).
// ──────────────────────────────────────────────────────────────────────────
public async Task<T?> GetAsync<T>(string logicalKey, string versionScope)
{
var versionedKey = await GetVersionedKeyAsync(logicalKey, versionScope);
var value = await _db.StringGetAsync(versionedKey);
if (value.IsNullOrEmpty) return default;
return JsonSerializer.Deserialize<T>(value!);
}
// ──────────────────────────────────────────────────────────────────────────
// SetAsync — cache write using the current versioned key.
// ──────────────────────────────────────────────────────────────────────────
public async Task SetAsync<T>(
string logicalKey,
string versionScope,
T value,
TimeSpan? ttl = null)
{
var versionedKey = await GetVersionedKeyAsync(logicalKey, versionScope);
var json = JsonSerializer.Serialize(value);
await _db.StringSetAsync(versionedKey, json, ttl ?? _defaultTtl);
}
// ──────────────────────────────────────────────────────────────────────────
// IncrementVersionAsync — the invalidation call.
// Increments the version token. All existing keys under this scope become
// unreachable immediately — no deletes, no Lua, no race conditions.
// Old keys expire naturally via their TTL.
// ──────────────────────────────────────────────────────────────────────────
public async Task<long> IncrementVersionAsync(string versionScope)
{
var newVersion = await _db.StringIncrementAsync(VersionKey(versionScope));
// Reset TTL on the version token after each increment so it does not
// expire while there are still active keys referencing this version.
await _db.KeyExpireAsync(VersionKey(versionScope), _versionTokenTtl);
return newVersion;
}
private static string VersionKey(string scope) => $"ver:{scope}";
}
// ──────────────────────────────────────────────────────────────────────────────
// Usage — the call site is clean: logical key + version scope.
// The versioned key construction is entirely internal to the service.
// ──────────────────────────────────────────────────────────────────────────────
public class ProductQueryService
{
private readonly VersionedCacheService _cache;
private readonly AppDbContext _db;
public async Task<Product?> GetProductAsync(int productId)
{
var cached = await _cache.GetAsync<Product>(
logicalKey: $"product:{productId}",
versionScope: $"product:{productId}" // entity-scoped version
);
if (cached is not null) return cached;
var product = await _db.Products.FindAsync(productId);
if (product is null) return null;
await _cache.SetAsync(
logicalKey: $"product:{productId}",
versionScope: $"product:{productId}",
value: product
);
return product;
}
}
public class ProductMutationService
{
private readonly VersionedCacheService _cache;
private readonly AppDbContext _db;
public async Task UpdateProductAsync(int productId, UpdateProductCommand cmd)
{
await _db.Products
.Where(p => p.Id == productId)
.ExecuteUpdateAsync(s => s
.SetProperty(p => p.Name, cmd.Name)
.SetProperty(p => p.Price, cmd.Price));
// Single INCR. All keys with versionScope "product:42" are now unreachable.
// No DEL calls. No Lua. No search. No race condition.
await _cache.IncrementVersionAsync($"product:{productId}");
// Optionally also invalidate the category-level scope if you cache
// list pages keyed by category version:
// await _cache.IncrementVersionAsync($"category:{cmd.CategoryId}");
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Coarse-grained invalidation: bump a site-wide version token to invalidate
// everything in one INCR — useful for deployments or bulk data migrations.
// ──────────────────────────────────────────────────────────────────────────────
public class DeploymentCacheFlushService
{
private readonly VersionedCacheService _cache;
public async Task OnDeploymentAsync()
{
// All versioned cache keys become unreachable after this single call.
await _cache.IncrementVersionAsync("site");
// Cache warms naturally as traffic hits the now-empty versioned keyspace.
}
}
The trade-off with versioned keys is memory: old keys remain in Redis until their TTL expires. With a 10-minute TTL this is acceptable — at most 10 minutes of memory overhead per invalidated version. If your TTLs are measured in hours and your invalidation frequency is high, the orphaned-key accumulation may become significant. In those cases, combine version tokens with a short TTL or fall back to tag-based eviction with explicit deletes.
Event-Driven Purge: Cross-Service Invalidation via Redis Pub/Sub
Tag-based eviction and versioned keys both require the invalidating code to have direct access to the Redis instance holding the stale data. In a microservices architecture where multiple services independently cache representations of the same domain entity, this direct coupling is not always available or appropriate. Event-driven purge decouples the mutation from the invalidation: the service that changes the data publishes a structured domain event to a Redis pub/sub channel; every service that caches a representation of that data subscribes to the channel and purges its own cache independently. The publisher does not know who is subscribed, and subscribers do not need a call path into the publisher.
using StackExchange.Redis;
using System.Text.Json;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
// ──────────────────────────────────────────────────────────────────────────────
// The invalidation event payload. Keep it small — this is pub/sub, not a
// message queue. The subscriber uses the EntityId to construct and purge
// its own cache keys; it does not need the full entity payload.
// ──────────────────────────────────────────────────────────────────────────────
public sealed record CacheInvalidationEvent(
string EntityType, // e.g. "product", "category", "user"
string EntityId, // e.g. "42", "electronics"
string Reason // e.g. "price-updated", "deleted", "bulk-import"
);
// ──────────────────────────────────────────────────────────────────────────────
// Publisher — called from the write path of the mutating service.
// Channel convention: "cache-invalidation:{entity-type}"
// Using a channel per entity type lets subscribers filter at the subscription
// level rather than in application code.
// ──────────────────────────────────────────────────────────────────────────────
public sealed class CacheInvalidationPublisher
{
private readonly IConnectionMultiplexer _redis;
public CacheInvalidationPublisher(IConnectionMultiplexer redis)
=> _redis = redis;
public async Task PublishAsync(CacheInvalidationEvent evt)
{
var sub = _redis.GetSubscriber();
var channel = RedisChannel.Literal($"cache-invalidation:{evt.EntityType}");
var payload = JsonSerializer.Serialize(evt);
await sub.PublishAsync(channel, payload);
// Fire and forget by design. Redis pub/sub is best-effort.
// A missed message causes temporary staleness, not data loss.
// The subscriber's TTL is the safety net.
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Subscriber — a background service in each consumer API.
// Subscribes to the channel on startup and purges its local cache entries
// when an event arrives. Hosted as an IHostedService so it lives for the
// lifetime of the application and reconnects on Redis connection restore.
// ──────────────────────────────────────────────────────────────────────────────
public sealed class ProductCacheInvalidationSubscriber : BackgroundService
{
private readonly IConnectionMultiplexer _redis;
private readonly TaggedCacheService _tagCache; // or VersionedCacheService
private readonly ILogger<ProductCacheInvalidationSubscriber> _logger;
public ProductCacheInvalidationSubscriber(
IConnectionMultiplexer redis,
TaggedCacheService tagCache,
ILogger<ProductCacheInvalidationSubscriber> logger)
{
_redis = redis;
_tagCache = tagCache;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var sub = _redis.GetSubscriber();
var channel = RedisChannel.Literal("cache-invalidation:product");
// Subscribe is synchronous registration; the handler is invoked on a
// StackExchange.Redis thread pool thread. Do not perform long-running
// blocking work inside the handler — offload if necessary.
await sub.SubscribeAsync(channel, async (_, message) =>
{
if (message.IsNullOrEmpty) return;
CacheInvalidationEvent? evt;
try
{
evt = JsonSerializer.Deserialize<CacheInvalidationEvent>(message!);
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Malformed cache invalidation event: {Raw}", (string?)message);
return;
}
if (evt is null) return;
_logger.LogInformation(
"Cache invalidation received: {Type}/{Id} — {Reason}",
evt.EntityType, evt.EntityId, evt.Reason);
// Purge this service's cache entries for the entity.
// The subscriber owns its own tag index — it registered these
// cache keys when it populated them.
await _tagCache.PurgeTagAsync($"product:{evt.EntityId}");
});
// Keep the hosted service alive until the application stops.
await Task.Delay(Timeout.Infinite, stoppingToken);
// Clean up subscription on graceful shutdown.
await sub.UnsubscribeAsync(channel);
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Wiring — Program.cs / Startup.cs
// ──────────────────────────────────────────────────────────────────────────────
// builder.Services.AddSingleton<IConnectionMultiplexer>(
// ConnectionMultiplexer.Connect("localhost:6379"));
// builder.Services.AddSingleton<TaggedCacheService>();
// builder.Services.AddSingleton<CacheInvalidationPublisher>();
// builder.Services.AddHostedService<ProductCacheInvalidationSubscriber>();
// ──────────────────────────────────────────────────────────────────────────────
// Write path in the Product service — publish after the DB write commits.
// ──────────────────────────────────────────────────────────────────────────────
public class ProductCommandHandler
{
private readonly AppDbContext _db;
private readonly CacheInvalidationPublisher _publisher;
public async Task HandleAsync(UpdateProductPriceCommand cmd)
{
await _db.Products
.Where(p => p.Id == cmd.ProductId)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, cmd.NewPrice));
// Publish AFTER the database write is committed, not before.
// Publishing before commit means subscribers may re-populate the
// cache from the database before the new value is visible.
await _publisher.PublishAsync(new CacheInvalidationEvent(
EntityType: "product",
EntityId: cmd.ProductId.ToString(),
Reason: "price-updated"
));
// Every subscribed service — search API, recommendations API,
// storefront API — purges its own product cache independently.
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Resilience note: Redis pub/sub does NOT persist messages.
// If a subscriber is offline when the event is published, it misses it.
// Mitigation strategies (choose based on staleness tolerance):
//
// 1. SHORT TTL (simplest) — set TTLs of 1–5 minutes. A missed event means
// staleness for at most one TTL period. Acceptable for most product data.
//
// 2. POLLING FALLBACK — subscribers poll a "last-modified" key per entity.
// On cache miss, compare the cached timestamp against the Redis timestamp.
// If stale, re-fetch. Adds one Redis GET per cache miss.
//
// 3. REDIS STREAMS (strongest) — replace pub/sub with a stream. Subscribers
// use consumer groups and track their last-read message ID. A reconnected
// subscriber replays missed events. Use when missed invalidations have
// material user or business impact.
// ──────────────────────────────────────────────────────────────────────────────
The critical ordering constraint — publish after the database write commits, not before — prevents a subscriber from re-caching a stale value during the window between publication and commit visibility. This is the most common event-driven invalidation mistake and it produces a staleness window that can outlast the TTL if the subscriber's cache-miss re-population races the commit.
Choosing the Right Strategy for Your API
The three strategies are not mutually exclusive and the most robust production systems combine them. Version tokens handle coarse-grained entity invalidation with zero delete overhead. Tag-based eviction handles fine-grained grouped invalidation where multiple related keys must be purged atomically. Event-driven purge handles cross-service propagation. The decision tree is simpler than it looks: start with the constraint, not the preference.
// ──────────────────────────────────────────────────────────────────────────────
// DECISION GUIDE
//
// Use VERSIONED KEYS when:
// - A single entity maps to a small, known set of cache keys
// - You can tolerate orphaned-key memory overhead until TTL expiry
// - You want the simplest possible implementation (one INCR = full invalidation)
// - Example: per-product detail pages, per-user preference data
//
// Use TAG-BASED EVICTION when:
// - One mutation affects multiple related cache keys (entity + list + widget)
// - You need immediate, atomic invalidation (not TTL-grace period)
// - All affected keys live in the same Redis instance / cluster slot is acceptable
// - Example: product update invalidating detail, category list, and homepage widget
//
// Use EVENT-DRIVEN PURGE when:
// - Multiple services independently cache the same entity
// - The mutating service cannot or should not call the consumer's cache directly
// - You accept fire-and-forget delivery (use Streams if you cannot)
// - Example: product service notifying search API and storefront API
//
// ──────────────────────────────────────────────────────────────────────────────
// COMBINATION PATTERN — used in production systems with moderate complexity
// ──────────────────────────────────────────────────────────────────────────────
public sealed class ProductInvalidationOrchestrator
{
private readonly VersionedCacheService _versionCache;
private readonly TaggedCacheService _tagCache;
private readonly CacheInvalidationPublisher _publisher;
public async Task InvalidateProductAsync(int productId, int categoryId)
{
// LAYER 1: Versioned key rotation — makes all versioned product keys
// unreachable. Handles the simple single-entity read path.
await _versionCache.IncrementVersionAsync($"product:{productId}");
// LAYER 2: Tag purge — atomically deletes all tagged derived keys
// (list pages, widgets) that version rotation does not cover because
// they may be keyed by category rather than by product.
await _tagCache.PurgeTagAsync($"product:{productId}");
await _tagCache.PurgeTagAsync($"category:{categoryId}");
// LAYER 3: Pub/sub event — notifies downstream services that maintain
// their own independent cache of this product's data.
await _publisher.PublishAsync(new CacheInvalidationEvent(
EntityType: "product",
EntityId: productId.ToString(),
Reason: "updated"
));
// All three layers execute in parallel in production — await Task.WhenAll
// if the write path can absorb the added latency of concurrent Redis calls.
}
}
// ──────────────────────────────────────────────────────────────────────────────
// WHAT NOT TO DO — anti-patterns that appear reasonable but break under load
// ──────────────────────────────────────────────────────────────────────────────
//
// Anti-pattern 1: Flushing the entire cache on every write
// await _db.ExecuteAsync("FLUSHDB"); ← never in production
// await _cache.RemoveAsync("*"); ← KEYS wildcard, not a real API
//
// Anti-pattern 2: Relying solely on TTL as "invalidation"
// Short TTLs (30s–2min) reduce staleness windows but are not invalidation.
// They are staleness tolerance, not consistency guarantees.
// Use TTL as a safety net, not as the primary invalidation mechanism.
//
// Anti-pattern 3: Invalidating before the DB write commits
// await _cache.PurgeTagAsync(...) ← WRONG: before SaveChangesAsync
// await _db.SaveChangesAsync();
// The cache is now empty; the next reader repopulates from the database
// before the write is visible. Staleness window = replication lag.
//
// Anti-pattern 4: Using IDistributedCache.RemoveAsync with SCAN-generated keys
// Iterated SCAN + RemoveAsync is not atomic and does not scale.
// If you find yourself iterating Redis keys from application code,
// the key design is wrong. Fix the key design, not the iteration.
The anti-patterns at the bottom of the code block account for the majority of cache invalidation production incidents in .NET APIs. The most insidious is anti-pattern 2 — treating a short TTL as a substitute for invalidation. A 30-second TTL means 30 seconds of guaranteed staleness after every mutation at high write frequency, which compounds: a product updated 10 times per minute never serves a fresh value to readers whose cache was populated between updates.