Redis Cache Invalidation for .NET APIs: Tag-Based Eviction, Versioned Keys & Event-Driven Purge

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.

InvalidationPitfalls.cs — Why Naive KeyDelete Breaks Under Real Conditions
// ──────────────────────────────────────────────────────────────────────────────
// 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.

TaggedCacheService.cs — Tag-Based Eviction via Redis Sets and Lua
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.

VersionedCacheService.cs — Version-Token Key Strategy
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.

EventDrivenCachePurge.cs — Redis Pub/Sub Invalidation for Distributed Services
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.

StrategyDecision.cs — Decision Comments and Combination Pattern
// ──────────────────────────────────────────────────────────────────────────────
// 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.

What Developers Ask About Cache Invalidation

What is the simplest Redis cache invalidation strategy for a .NET API?

Versioned keys are the simplest to implement and the safest to reason about. Prefix every cache key with a version token stored separately in Redis — v3:product:42 rather than product:42. When the entity changes, increment the version with a single INCR call. The old keys become unreachable without requiring any delete operation, and they expire naturally when their TTL passes. For APIs with TTLs measured in minutes, this is the lowest-risk starting point. Tag-based eviction and event-driven purge are appropriate when you need immediate consistency or when multiple derived keys must be invalidated atomically.

Why is using KEYS or SCAN to find and delete cache entries by pattern dangerous in production?

KEYS blocks the Redis event loop for the entire scan — on a keyspace with millions of entries, this can pause Redis for seconds, causing timeout cascades across every service sharing that instance. SCAN is non-blocking but not atomic: keys added or deleted during the scan may be missed or double-visited. Neither command is reliable for production invalidation. The correct approach is to maintain an explicit index of related keys — a Redis Set per tag — and delete its members atomically using a Lua script. Your application owns the index; Redis does not need to search its own keyspace.

How do I invalidate multiple related cache keys atomically in Redis?

Use a Lua script via ScriptEvaluateAsync in StackExchange.Redis. Lua scripts run atomically on the Redis server — no other command can interleave during execution. The pattern: maintain a Redis Set per tag whose members are the cache keys carrying that tag. The Lua script reads the set members, deletes each member key, then deletes the tag set itself in one atomic operation. Reading the set in .NET and issuing individual DEL commands is not atomic and will miss keys added between the read and the delete under concurrent write load.

What is the difference between tag-based eviction and event-driven cache purge?

Tag-based eviction is synchronous and caller-initiated: the code that mutates the data calls the invalidation method in the same request pipeline. Event-driven purge is asynchronous and decoupled: the mutating service publishes a domain event to a Redis pub/sub channel, and subscriber services perform their own independent cache purge on receipt. Tag-based eviction is appropriate when a single service owns both the data mutation and the cache. Event-driven purge is appropriate when multiple services independently cache the same logical entity and cannot share a direct call path — for example, a product service notifying a search API and a storefront API.

Does Redis pub/sub guarantee message delivery for cache invalidation events?

No. Redis pub/sub is fire-and-forget with no persistence and no delivery guarantee. A subscriber that is offline when the message is published will miss it, resulting in temporarily stale cache entries until the TTL expires. For most .NET API cache invalidation scenarios this is acceptable — staleness is bounded by the TTL and a missed event is not data loss. If your application cannot tolerate a missed invalidation, use Redis Streams instead: Streams persist messages, support consumer groups, and allow a reconnected subscriber to replay missed events from its last-acknowledged position.

Back to Articles