Mert Özen Further With Every Line
Backend Caching — #03

IDistributedCache and Running Redis as a Cache: Serialization, Key Design, TTL and Eviction Policy

Mert Özen Nov 8, 2026 13 min 2 views
IDistributedCache and Running Redis as a Cache: Serialization, Key Design, TTL and Eviction Policy

Running Redis as a cache takes a single ConnectionMultiplexer, short timeouts, versioned keys and allkeys-lru. With default settings every request took 11-12 s once Redis stopped; a 200 ms WaitAsync cap cut that to 0.4 s and a simple circuit breaker to under 1 ms. The official redis:8.2 image ships with noeviction: at 8 MB writes failed with OOM, while allkeys-lru kept going by evicting 2,534 keys. Redis 8 adds an AGPLv3 option; Valkey is BSD and Garnet MIT licensed.

Version note: The code in this post was built and run with .NET 10; the examples ran with Redis 8.2 (8.2.11), Microsoft.Extensions.Caching.StackExchangeRedis 10.0.14, StackExchange.Redis 3.4.31 and MessagePack 3.1.12.

Guess First

The book detail endpoint of the Bookstore API (Application Programming Interface) works cache-aside: it checks Redis first, and on a miss it reads from SQLite and writes to Redis. The Redis calls are inside a try/catch; on an error it skips the cache and answers from the database. The connection string is just localhost:17511; none of StackExchange.Redis's settings have been touched.

Two questions. First: the Redis container stops. How many seconds does a request take? Second: on the official redis:8.2 image you set maxmemory to 8 MB and keep writing books with a 10-minute TTL (Time to Live). What happens when memory fills up?

Where We Pick Up

In the ASP.NET Core Web API series, "Caching in ASP.NET Core Web API: IMemoryCache, Redis and Response Caching" took a first look at Redis with AddStackExchangeRedisCache, InstanceName and GetStringAsync/SetStringAsync. The System Design series post "Caching Strategies: Cache-Aside, Write-Through, Write-Behind and Cache Stampede" explained the difference between LRU (Least Recently Used) and LFU (Least Frequently Used) and noted that Redis's default policy is noeviction. This post builds on that foundation with the .NET side of running Redis as a cache in production: connections, serialization, key design, memory limits and staying up when Redis is gone. Redis's data structures, persistence, clustering and pub/sub belong to the upcoming Database › Redis series; here Redis is only a cache.

A Single ConnectionMultiplexer and StackExchange.Redis 3.x

The first surprise is in the package tree. When you add Microsoft.Extensions.Caching.StackExchangeRedis 10.0.14, the client you get is not 3.x:

$ dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis --version 10.0.14
$ dotnet list package --include-transitive | grep StackExchange.Redis
   > StackExchange.Redis               2.7.27
$ dotnet add package StackExchange.Redis --version 3.4.31
$ dotnet list package --include-transitive | grep StackExchange.Redis
   > StackExchange.Redis                                  3.4.31      3.4.31

The package declares a lower bound and NuGet picks that bound. StackExchange.Redis 3.0 shipped in June 2026; according to its GitHub release notes it intentionally leaves the public API unchanged, rewrites the network layer and the parser, and makes RESP3 (Redis Serialization Protocol 3) the default protocol. The one behavioral difference is that admin commands such as Execute("FLUSHALL") are now rejected on the client when AllowAdmin is off. If you want the new version, reference the package directly.

The second topic is the connection itself. A ConnectionMultiplexer is an expensive object: it manages TCP (Transmission Control Protocol) connections, reconnection logic and the command queue, and there should be one per application. AddStackExchangeRedisCache opens its own multiplexer. If other parts of the app use Redis directly, share a single multiplexer so you don't open a second connection pool:

ConfigurationOptions redis = ConfigurationOptions.Parse(builder.Configuration.GetConnectionString("Redis")!);
redis.AbortOnConnectFail = false; // RedisCache doesn't set this for us when we pass a factory
IConnectionMultiplexer mux = await ConnectionMultiplexer.ConnectAsync(redis);

builder.Services.AddSingleton(mux); // the app's single multiplexer; code that uses Redis directly gets this one too
builder.Services.AddStackExchangeRedisCache(o =>
{
    o.InstanceName = "bookstore:";
    o.ConnectionMultiplexerFactory = () => Task.FromResult(mux);
});

The detail in the comment matters. In the RedisCache source, when it connects from the connection string itself, it sets AbortOnConnectFail = false, and the comment is explicit: a server that's unavailable at startup shouldn't prevent the DI (Dependency Injection) container from creating the service. When you pass a factory, that setting is your responsibility. With the default true, the app doesn't start at all while Redis is down:

Unhandled exception. StackExchange.Redis.RedisConnectionException: It was not possible to connect to the redis server(s). Error connecting right now. To allow this multiplexer to continue retrying until it's able to connect, use abortConnect=false in your connection string or AbortOnConnectFail=false; in your code.

With false, under the same conditions the API started and the first request returned 200 from the database; the multiplexer keeps trying to connect in the background.

Serialization and What It Becomes in Redis

IDistributedCache stores byte arrays; you serialize your type yourself. The default choice is System.Text.Json, but with source generation instead of reflection:

[MessagePackObject]
public sealed record BookDetail(
    [property: Key(0)] int Id,
    [property: Key(1)] string Title,
    [property: Key(2)] string Author,
    [property: Key(3)] decimal Price,
    [property: Key(4)] DateOnly PublishedOn,
    [property: Key(5)] string[] Tags,
    [property: Key(6)] string Description);

// serializer generated at compile time: no reflection, trimming/AOT friendly
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
[JsonSerializable(typeof(BookDetail))]
public sealed partial class CacheJsonContext : JsonSerializerContext;

We serialized the same book three ways and timed 100,000 serialize/deserialize round trips:

System.Text.Json (reflection): 499 bytes
System.Text.Json (source gen): 499 bytes
MessagePack                  : 413 bytes
STJ source gen  100k round trips: 217 ms
MessagePack     100k round trips: 142 ms

Source generation doesn't change the size; its gains are not building reflection metadata at startup and compatibility with trimming and Native AOT (Ahead-of-Time). MessagePack is binary, so it's 17 percent smaller and about 1.5 times faster on this machine. The price is readability and schema discipline: you can look at JSON (JavaScript Object Notation) with redis-cli and understand a bug, but not MessagePack; and if you change the Key(n) numbers, old entries get read into the wrong fields. JSON is enough for most caches; move to MessagePack when payloads are large and the CPU (Central Processing Unit) cost becomes measurable.

Let's look at what the entry becomes on the Redis side (we run redis-cli inside the container with docker exec):

$ redis-cli --scan --pattern 'bookstore:*'
bookstore:v2:book:42:msgpack
bookstore:v2:book:42
$ redis-cli TYPE bookstore:v2:book:42
hash
$ redis-cli HGETALL bookstore:v2:book:42
absexp
639297017962054830
sldexp
-1
data
{"id":42,"title":"The Pragmatic Bookstore","author":"Ada Lovelace","price":39.90,"publishedOn":"2024-05-14","t
$ redis-cli TTL bookstore:v2:book:42
593
$ redis-cli OBJECT ENCODING bookstore:v2:book:42
hashtable
$ redis-cli CONFIG GET hash-max-listpack-value
hash-max-listpack-value
64
$ redis-cli MEMORY USAGE bookstore:v2:book:42
776
$ redis-cli MEMORY USAGE bookstore:v2:book:42:msgpack
720

RedisCache writes each entry as a hash with three fields: absolute expiration in .NET ticks, sliding expiration, and the data. The 499-byte JSON takes 776 bytes in Redis; because the field is longer than 64 bytes, the hash is stored as a hashtable instead of the compact listpack. The source has two more details. A write pipelines the HSET and EXPIRE commands in a batch; that's not a MULTI/EXEC transaction, so if the connection drops right in between, a key without a TTL can be left behind. If you use sliding expiration, every read sends an extra EXPIRE; in a read-heavy cache that means two commands per read. Absolute expiration is usually the better choice.

Key Design and Versioning

Every part of the key has a job: bookstore:v2:book:42.

bookstore: comes from InstanceName and separates applications sharing the same Redis. v2 is the schema version: when you add a field to BookDetail or switch to MessagePack you bump the version, the new code never reads the old format, old and new instances don't corrupt each other's entries during a deploy, and the old keys die on their own through TTL. book:42 is the identity of the entity. This layout also makes it easy to inspect one version or one entity type with SCAN. Don't use KEYS *; it scans the whole keyspace in a single command, and Redis processes no other command meanwhile.

Keep keys short but readable, and normalize user input before putting it into a key; in the second post of the series we saw how unbounded key generation bloats IMemoryCache, and in Redis the same problem eats the shared memory of every instance.

maxmemory and Eviction Policy

The official image's defaults:

$ redis-cli CONFIG GET maxmemory-policy
maxmemory-policy
noeviction
$ redis-cli CONFIG GET maxmemory
maxmemory
0

maxmemory 0 means unlimited on 64-bit systems; Redis grows until it runs out of the machine's memory. We set the limit to 8 MB and applied the same load with three policies. Each entry is a book with a 2,000-character description:

$ redis-cli CONFIG SET maxmemory 8mb
OK
--- noeviction, TTL 600 s
write 2426 failed: RedisServerException: OOM command not allowed when used memory > 'maxmemory'.
done, keys in db: 2425
--- volatile-lru, no TTL
write 2450 failed: RedisServerException: OOM command not allowed when used memory > 'maxmemory'.
done, keys in db: 2449
--- allkeys-lru, no TTL
done, keys in db: 2479
$ redis-cli INFO stats | grep -E "^(evicted_keys|expired_keys)"
expired_keys:0
evicted_keys:2534
$ redis-cli INFO memory | grep -E "^(used_memory_human|maxmemory_human|maxmemory_policy)"
used_memory_human:8.00M
maxmemory_human:8.00M
maxmemory_policy:allkeys-lru

The answer to the second guess: with noeviction, once memory is full every write gets an OOM (Out Of Memory) error; reads keep working, but the cache accepts no new data. volatile-lru only picks among keys that have a TTL; if memory is full of keys without a TTL, it behaves like noeviction. That means a single code path calling SetAsync without options can lock up the whole cache. allkeys-lru, on the other hand, stayed within the limit by evicting 2,534 keys.

PolicyEvictsFor a cache
noevictionNothing; writes failNot suitable
allkeys-lruAny key, least recently accessed firstGood default
allkeys-lfuThe least frequently accessed keyWhen the hot set is clear and stable
volatile-lru / volatile-lfuOnly among keys with a TTLWhen Redis is also used for more than caching
volatile-ttlThe key with the shortest remaining TTLWhen TTLs reflect importance
allkeys-lrm / volatile-lrmLeast recently written (Redis 8.6+)To tell frequently read but rarely updated data apart

The Redis documentation recommends allkeys-lru as a good default if you have no particular reason to prefer another. Redis's LRU is an approximation: on each eviction it samples maxmemory-samples keys (default 5) and evicts the oldest. The LRM (Least Recently Modified) policies arrived in Redis 8.6; they don't exist in the 8.2 we tested. One more observation: when we restarted the container after the experiments, CONFIG GET maxmemory-policy returned noeviction again. CONFIG SET isn't persistent; put the setting in redis.conf or in the container command (--maxmemory 512mb --maxmemory-policy allkeys-lru). If you're going to use Redis for both caching and durable data, the documentation recommends two separate instances.

When Redis Goes Down

A cache is an accelerator, not a dependency. If Redis can't be reached, the request should be answered from the database, and quickly. The book detail endpoint doesn't talk to Redis directly but through this wrapper:

// Redis is an accelerator, not a dependency: error = miss, then skip Redis entirely for a while
public sealed class SafeCache(IDistributedCache cache, SafeCacheOptions options, TimeProvider time, ILogger<SafeCache> logger)
{
    private long _skipUntilTicks;

    public async Task<T?> GetAsync<T>(string key, JsonTypeInfo<T> type, CancellationToken ct) where T : class
    {
        if (IsOpen()) return null;
        try
        {
            // SE.Redis checks async timeouts on the heartbeat (once per second); we set the hard upper bound
            byte[]? bytes = await cache.GetAsync(key, ct).WaitAsync(options.Timeout, ct);
            return bytes is null ? null : JsonSerializer.Deserialize(bytes, type);
        }
        catch (Exception ex) when (ex is RedisException or TimeoutException)
        {
            Trip(ex);
            return null;
        }
    }

    public async Task SetAsync<T>(string key, T value, JsonTypeInfo<T> type, DistributedCacheEntryOptions entry, CancellationToken ct)
    {
        if (IsOpen()) return;
        try
        {
            await cache.SetAsync(key, JsonSerializer.SerializeToUtf8Bytes(value, type), entry, ct).WaitAsync(options.Timeout, ct);
        }
        catch (Exception ex) when (ex is RedisException or TimeoutException)
        {
            Trip(ex);
        }
    }

    private bool IsOpen() => time.GetUtcNow().UtcTicks < Interlocked.Read(ref _skipUntilTicks);

    private void Trip(Exception ex)
    {
        Interlocked.Exchange(ref _skipUntilTicks, (time.GetUtcNow() + options.BreakDuration).UtcTicks);
        logger.LogWarning("Redis unavailable, skipping cache for {Seconds} s: {Error}",
            options.BreakDuration.TotalSeconds, ex.GetType().Name);
    }
}
app.MapGet("/api/books/{id:int}", async (int id, SafeCache cache, CatalogDb db, CancellationToken ct) =>
{
    string key = $"v2:book:{id}";
    BookDetail? book = await cache.GetAsync(key, CacheJsonContext.Default.BookDetail, ct);
    if (book is not null) return Results.Ok(book);

    book = await db.Books.AsNoTracking().Where(b => b.Id == id)
        .Select(b => new BookDetail(b.Id, b.Title, b.Author, b.Price, b.PublishedOn, b.Tags.Split(',', StringSplitOptions.None), b.Description))
        .FirstOrDefaultAsync(ct);
    if (book is null) return Results.NotFound();

    await cache.SetAsync(key, book, CacheJsonContext.Default.BookDetail, bookTtl, ct);
    return Results.Ok(book);
});

We ran the same app with four settings: with Redis up, one miss and one hit, then docker stop, then five requests, then docker start and two requests. The relevant lines from each setting:

== defaults
redis up,   hit  status=200 time=0.003260s
redis down, req 1 status=200 time=11.093907s
redis down, req 5 status=200 time=11.984520s
== short
redis down, req 1 status=200 time=1.318846s
redis down, req 5 status=200 time=1.985306s
== waitasync
redis down, req 1 status=200 time=0.409507s
redis down, req 5 status=200 time=0.406430s
== breaker
redis down, req 1 status=200 time=0.205936s
redis down, req 2 status=200 time=0.001142s
redis down, req 5 status=200 time=0.000734s
redis back, req 1 status=200 time=0.001299s

The answer to the first guess: about 11–12 seconds, for every request. Having a try/catch isn't enough. The default ConnectTimeout, SyncTimeout and AsyncTimeout are 5,000 ms; with no connection, commands wait in the backlog for the connection to come back and fail with RedisConnectionException when the time runs out, first the read, then the write. On the short line we added connectTimeout=1000,syncTimeout=250,asyncTimeout=250 to the connection string, and the time dropped to 2 seconds, not 250 ms. The reason is in StackExchange.Redis's own documentation: async timeouts are only checked on the heartbeat, which by default runs once per second. That's also why the documentation recommends WaitAsync for async operations; on the waitasync line we capped every Redis call at 200 ms, and the request dropped to 0.4 seconds, the sum of the read and the write. The last step is a simple circuit breaker: after the first error we don't go to Redis at all for 15 seconds; the first request takes 0.2 seconds, the following ones under 1 ms. When Redis comes back we keep going without the cache until the window ends, and the next request tries Redis again.

This breaker is deliberately primitive: a single error opens the window. Details such as thresholds, the half-open state and retries are in the System Design series post "Reliability Patterns: Timeouts, Retries, Backoff with Jitter, Circuit Breakers and Load Shedding". Two more warnings: WaitAsync releases the caller, but the command may still reach Redis later, and while Redis is gone the whole load lands on the database. A query that takes 1 ms on SQLite can climb to seconds on a real database under load; the countermeasures are in the eighth post of the series.

Licensing: Redis 8, Valkey and Garnet

If you self-host Redis, the license is an architectural decision. Abbreviations used in the table: RSALv2 (Redis Source Available License v2), SSPLv1 (Server Side Public License v1), AGPLv3 (GNU Affero General Public License v3), OSI (Open Source Initiative), BSD (Berkeley Software Distribution), the MIT (Massachusetts Institute of Technology) license and RESP (Redis Serialization Protocol). According to the primary sources, the situation is:

ServerLicenseNote
Redis 7.2.x and earlierBSD-3-ClauseClassic open source Redis
Redis 7.4.x–7.8.xRSALv2 or SSPLv1The March 2024 change; neither is OSI-approved
Redis 8 and laterRSALv2, SSPLv1 or AGPLv3The user picks one; AGPLv3 is OSI-approved
ValkeyBSD 3-ClauseLinux Foundation; forked from Redis 7.2.4 in March 2024
GarnetMITMicrosoft Research; written in .NET, speaks RESP

Redis's licensing page states explicitly that RSALv2 and SSPLv1 are "not an open source license"; the AGPLv3 option was added to Redis 8 with an announcement dated May 1, 2025. The network-use clause of AGPLv3 mainly concerns those who modify Redis itself and offer it as a service, but the binding interpretation is your legal team's job. Valkey started under the Linux Foundation in response to the license change, and according to valkey.io its current version is 9.1.3. Garnet is Microsoft Research's cache-store; because it speaks RESP, it works with unmodified Redis clients.

On the .NET side the code is the same for all three: RedisCache and StackExchange.Redis work with any server that speaks RESP. The measurements in this post were taken only with Redis 8.2; if you're moving to Valkey or Garnet, verify with your own load and the commands you actually use.

Production Notes

Set maxmemory and maxmemory-policy in the configuration file or as container arguments, and don't rely on CONFIG SET; we saw it vanish on restart. Don't set the limit to the machine's entire memory; leave room for replication buffers and fragmentation.

Watch evicted_keys, keyspace_hits and keyspace_misses in INFO stats. If the eviction count keeps climbing, the cache is too small or the TTLs are longer than needed; we'll cover the hit ratio and exporting these metrics in the ninth post of the series.

Measure payload sizes. Look at a few typical keys with MEMORY USAGE; the hash overhead comes on top of the JSON. Reading a 100 KB entry on every request strains the network and Redis more than you think.

Derive timeouts from the app's latency budget. A 5-second default is absurd for a cache; if Redis doesn't answer within 200 ms, going to the database is faster.

Common Pitfalls

Calling SetAsync without DistributedCacheEntryOptions. The key is written without a TTL, is never evicted under the volatile-* policies, and stays forever even if the schema changes.

Opening a new ConnectionMultiplexer on every request. Each one sets up its own connections; under load you head for Redis's connection limit and port exhaustion.

Passing a multiplexer through a factory and forgetting AbortOnConnectFail. A short Redis outage then brings down a deploy or a pod restart.

Running KEYS in production. A debug script's KEYS bookstore:* on an instance with millions of keys makes every client wait; use SCAN.

Treating Redis as a dependency. An endpoint that returns 500 or waits 12 seconds when it can't read the cache has forgotten why the cache exists.

A Small Experiment

★☆☆ Repeat the eviction experiment with allkeys-lfu and read a few keys over and over while writing. Look at the counters with OBJECT FREQ; which keys survive?

★★☆ Add an abstraction to SafeCache that takes the serializer from outside, and write 1,000 books first with JSON, then with MessagePack. Is the difference in used_memory_human from INFO memory consistent with the single-key measurement?

★★★ Run the Redis-down experiment with docker pause instead of docker stop. In that case the TCP connection stays open but no answer comes back. How do the timings of the four settings change, and which setting really protects you against a network partition?

In this post we set up the .NET side of running Redis as a cache: a single multiplexer, explicit timeouts, versioned keys, the right maxmemory-policy, and falling back to the database quickly when Redis is gone. We now have two separate layers: the fast but per-instance IMemoryCache and the shared Redis with its network cost. In the next post we take an in-depth look at HybridCache, which combines the two: the L1 (level 1, in-process) and L2 (level 2, shared) layers, tags, logical invalidation and L1 staleness across servers.