RedisNearCache Client-side caching on top of StackExchange.Redis NuGet GitHub
Overview

Introduction #

A near cache for .NET whose entries are invalidated by the Redis server itself.

RedisNearCache keeps an in-process copy (an L1) of the Redis values your application reads, and lets the Redis server tell you when one of them changes, using the CLIENT TRACKING feature Redis 6 introduced.

The one-sentence mechanism: your app keeps an in-process copy of the Redis values it reads, Redis remembers which keys each tracked connection has read, and when any client writes one of those keys, Redis pushes a message naming it and the local copy is thrown away. Nothing in the write path has to cooperate: a Java service, a Go job, or someone at redis-cli all invalidate you the same way.

Architecture: your code calls IRedisNearCache, which serves from L1 or reads over a private interactive connection; Redis sends invalidations to a private subscriber connection which evicts from L1; your own multiplexer and any other writer send writes to Redis directly.
RedisNearCache opens its own private connection to Redis. Your application’s existing IConnectionMultiplexer is never touched, reconfigured, or depended on.

Why server-assisted beats a backplane

FusionCache and the proposed HybridCache backplane solve staleness with an application-level pub/sub channel that the writer publishes to. That only works when every writer uses the same library and remembers to publish. A writer in another language or process silently leaves readers stale. Server-assisted tracking moves the responsibility to the one place every write already passes through: Redis itself. Any client, in any language, doing a plain SET or DEL (or redis-cli by hand) causes Redis to push an invalidation, with no cooperation required from writers.

Requirements

RequirementDetail
Redis serverRedis 6 or newer, or Valkey (any version that implements CLIENT TRACKING).
GarnetNot supported. Garnet does not implement CLIENT TRACKING.
.NET.NET 8 or .NET 10.
StackExchange.Redis3.2.0 (pinned; see Directory.Packages.props).
Your own connectionNo special requirements. Any protocol (RESP2 or RESP3), no admin mode needed.
RedisNearCache's private connectionOpened by RedisNearCache itself, forced to RESP2 with admin mode (AllowAdmin=true) and its own client name. Required for CLIENT TRACKING and CLIENT LIST, and because StackExchange.Redis 3.x swallows RESP3 invalidation push frames.
installbash
dotnet add package RedisNearCache

One package for the core near cache. RedisNearCache.HybridCache is a separate, optional package covered in HybridCache and IDistributedCache.

Get started

Quickstart #

Install the package, register the cache, and watch an outside write invalidate it.

1. Install

Add the package to your project.

shellbash
dotnet add package RedisNearCache

2. Register and resolve

Register IRedisNearCache against a connection string and resolve it from DI. Await Ready once at startup so the invalidation subscription and the initial tracking arm have completed before you rely on the cache being coherent.

Program.cscsharp
using Microsoft.Extensions.DependencyInjection;
using RedisNearCache;

var services = new ServiceCollection();
services.AddRedisNearCache("localhost:6379");

await using var provider = services.BuildServiceProvider();
var cache = provider.GetRequiredService<IRedisNearCache>();

// Wait for the invalidation subscription and initial arming to complete.
await cache.Ready;

3. Write, then read

SetAsync writes through to Redis and evicts any local copy. The first GetAsync<T> after that is always a miss: it fetches from Redis, stores the value in L1, and arms server-side tracking for that key. Every read after that is served from L1 with no network call, until the key is invalidated.

read and removecsharp
await cache.SetAsync("user:42", new { Name = "Ada" });

var user = await cache.GetAsync<User>("user:42");     // miss: reads Redis, populates L1, arms tracking
var again = await cache.GetAsync<User>("user:42");    // hit: served from L1, no network call

await cache.RemoveAsync("user:42");

if (cache.TryGetLocal<User>("user:42", out var local))
{
    // local is only set if the key is currently cached in L1; Redis is never touched.
}

record User(string Name);

4. See an invalidation from redis-cli

Read a key so it is cached, then change it from a completely different client, one that knows nothing about RedisNearCache. The very next read reflects the new value, because Redis itself pushed the invalidation.

shellbash
# in another terminal, or from any language's Redis client
redis-cli SET user:42 '{"Name":"Grace"}'

Back in your application, the next GetAsync<T>("user:42") is a miss (it goes to Redis to re-populate L1 and re-arm tracking) and returns Grace. Every read after that is a hit again, until the next external write.

statscsharp
Console.WriteLine(cache.Statistics);
// hits=1 misses=1 invalidations=0 flushes=0 rearms=0 raceDiscards=0

5. Check the stats

Statistics exposes six cumulative, thread-safe counters. See Operations for what to watch in production.

Mechanism

How it works #

The private connection, one-shot tracking, NOLOOP writes, and the in-flight guard.

Why not an app-level backplane

FusionCache and the proposed HybridCache backplane solve staleness with pub/sub that the writer publishes to. That only works when every writer uses the same library. Server-assisted tracking moves the responsibility to the one place every write already passes through.

A. Pub/sub backplane.NET writerGo writerRedischannel.NET reader + L1publishSETevictSETGo writer never publishes → reader stays staleB. Server-assisted (this package).NET writerGo writerRedistracks keys per conn.NET reader + L1SETSETinvalidate keyAny writer → Redis sends the invalidation
In A the reader is only as fresh as the most disciplined writer. In B the server is the single source of invalidations.

The parts

RedisNearCache opens one private StackExchange.Redis multiplexer, cloned from your connection settings with Protocol=Resp2, AllowAdmin=true and a unique client name. Under RESP2 that multiplexer has two connections per Redis node: an interactive connection, used for the actual GET/SET calls, and a subscriber connection, which RedisNearCache tells Redis to redirect invalidations to (CLIENT TRACKING ON REDIRECT <subscriber id>). The subscriber subscribes to __redis__:invalidate and turns each incoming message into an eviction from L1.

YOUR PROCESS Your code IRedisNearCache L1 · MemoryCache in-flight tracker Private multiplexer RESP2 · admin · own client name interactive · tracked reads subscriber · redirect target Your multiplexer untouched Redis 6+ / Valkey tracking table key → client ids CLIENT TRACKING ON REDIRECT <sub id> NOLOOP Other writers redis-cli · Java · Go · Lua GetAsync GET on miss GET key · server tracks it invalidate [key] evict writes from your multiplexer go straight to Redis and invalidate like any other writer SET key
Only reads that go through the package are tracked, because only the package’s interactive connection is tracked. Writes can come from anywhere.
reads and writesinvalidationlocal cache

One-shot tracking

Tracking is one-shot per key: after Redis sends an invalidation for a key, it forgets that key was being tracked until the connection reads it again. Every GetAsync<T> miss re-arms tracking for that key by reading it over the tracked interactive connection.

sequenceDiagram
    autonumber
    participant App as Your code
    participant NC as RedisNearCache
    participant L1
    participant I as interactive conn
    participant S as subscriber conn
    participant R as Redis
    participant W as Any writer
    App->>NC: GetAsync("user:42")
    NC->>L1: TryGet
    L1-->>NC: miss
    NC->>NC: inflight.Begin(user:42)
    NC->>I: GET user:42
    I->>R: GET user:42
    Note over R: remembers user:42 was read by this connection
    R-->>I: value v1
    I-->>NC: value v1
    NC->>NC: invalidated while in flight? no
    NC->>L1: Set(user:42, v1)
    NC-->>App: v1
    App->>NC: GetAsync("user:42")
    NC->>L1: TryGet
    L1-->>NC: hit
    NC-->>App: v1 (no network)
    W->>R: SET user:42 v2
    R->>S: message __redis__:invalidate [user:42]
    Note over R: forgets user:42 for this connection (one-shot)
    S->>NC: KeyInvalidated(user:42)
    NC->>L1: Remove(user:42)
    App->>NC: GetAsync("user:42")
    NC->>L1: TryGet
    L1-->>NC: miss
    NC->>I: GET user:42
    I->>R: GET user:42
    Note over R: tracks user:42 again
    R-->>NC: value v2
    NC->>L1: Set(user:42, v2)
    NC-->>App: v2
Tracking is one-shot per key. After the invalidation Redis forgets the key until the next read re-tracks it.

NOLOOP

Writes made through RedisNearCache’s own SetAsync/RemoveAsync are armed with NOLOOP, so Redis does not echo them back as invalidations; RedisNearCache evicts its own L1 entry for the key directly around the write instead.

The in-flight guard

Because an invalidation can arrive between sending a GET and storing its reply, RedisNearCache keeps an in-flight guard: each read records a version token before the request goes out, and the reply is only stored in L1 if no invalidation for that key was seen in the meantime. A stale reply is still returned to the caller (it was correct when read); it is simply not cached, and Statistics.RaceDiscards counts it.

sequenceDiagram
    participant NC as RedisNearCache
    participant L1
    participant R as Redis
    participant W as Any writer
    NC->>NC: token = inflight.Begin(k)  (version 7)
    NC->>R: GET k
    R-->>NC: v1 (in transit)
    W->>R: SET k v2
    R->>NC: invalidate [k]
    NC->>NC: inflight.MarkInvalidated(k)  (version 8)
    NC->>L1: Remove(k)  (nothing there yet)
    Note over NC: reply v1 arrives
    NC->>NC: WasInvalidated(k, token)? version 8 ≠ 7 → yes
    NC->>NC: discard v1, Statistics.RaceDiscards++
    Note over NC: return v1 to the caller (it was true when read); next read fetches v2
The reply is still returned to the caller, which is correct for a read that was valid when issued. It is simply not cached.

Reconnects, the part that actually needs care

Tracking is per-connection state on the server. The spike showed two silent failure modes, both handled by re-arming and flushing.

What breaksWhat the server doesWhat we do
Interactive connection drops and reconnectsNew connection, tracking off (TRACKINGINFO flags=off, redirect=-1). Reads succeed untracked.On ConnectionRestored(Interactive): re-issue CLIENT TRACKING ON REDIRECT, flush L1.
Subscriber connection drops and reconnectsNew client id. The interactive connection still redirects to the dead id. Invalidations vanish with no error.On ConnectionRestored(Subscription): look up the new id in CLIENT LIST, TRACKING OFF then ON REDIRECT <new id>, flush L1.
Any connection failed, not yet restoredNothing can be trusted.Flush L1; serve from Redis until re-armed.
sequenceDiagram
    participant A as TrackingArmer
    participant L1
    participant I as interactive conn (id 26)
    participant S as subscriber conn (id 27 → 58)
    participant R as Redis
    Note over R: redirect target for id 26 is 27
    R-xS: connection killed
    S->>R: reconnect, CLIENT ID = 58
    Note over R: id 26 still redirects to dead 27
    S-->>A: ConnectionRestored(Subscription)
    A->>L1: Clear()  (anything missed is gone)
    A->>R: CLIENT LIST (via interactive)
    R-->>A: our subscriber = 58
    A->>R: CLIENT TRACKING OFF
    A->>R: CLIENT TRACKING ON REDIRECT 58
    R-->>A: OK
    A-->>L1: Armed(SubscriptionRestored)
Measured in the spike: the subscriber came back in about 60 ms with a new id while the server kept redirecting to the old one.

Per-endpoint state

stateDiagram-v2
    [*] --> Unarmed
    Unarmed --> Arming: StartAsync / find subscriber id
    Arming --> Armed: TRACKING ON REDIRECT ok
    Arming --> Arming: subscriber not visible yet, backoff retry
    Armed --> Lost: ConnectionFailed (either type)
    Lost --> Arming: ConnectionRestored
    Armed --> Arming: ConnectionRestored (server dropped or re-pointed)
    note right of Armed: L1 trusted
    note right of Lost: L1 flushed, reads pass through
One state machine per master node. Every transition into Arming after the first clears L1.

Cluster

Client ids and tracking tables live on each node, and a node sends invalidations only for keys it owns. The private multiplexer already keeps an interactive and a subscriber connection per master, so the armer repeats the same dance per node with that node’s subscriber id.

flowchart LR
    subgraph P[private multiplexer]
      direction TB
      A1[interactive → 7100]
      S1[subscriber id 11]
      A2[interactive → 7101]
      S2[subscriber id 7]
      A3[interactive → 7102]
      S3[subscriber id 7]
    end
    subgraph C[cluster]
      N1[(master 7100
slots 0-5460)] N2[(master 7101
slots 5461-10922)] N3[(master 7102
slots 10923-16383)] end A1 -- "TRACKING ON REDIRECT 11" --> N1 A2 -- "TRACKING ON REDIRECT 7" --> N2 A3 -- "TRACKING ON REDIRECT 7" --> N3 N1 -. "invalidate spike:c1" .-> S1 N2 -. "invalidate spike:c3" .-> S2 N3 -. "invalidate spike:c2" .-> S3
Ids from the spike’s cluster run: two nodes both handed out id 7, which is why an id is only meaningful together with its node.

What was ruled out, and why

OptionFinding
Use your existing multiplexerEvery CLIENT TRACKING and CLIENT LIST call needs allowAdmin, and every read on it would be tracked, cache or not.
RESP3 push messages (the 3.x default)StackExchange.Redis collapses to one connection and drops the invalidate pushes. Server says tracking is on; zero messages arrive.
Lua to bypass admin modeCLIENT TRACKING is flagged noscript.
OPTIN / CLIENT CACHING YESMust be adjacent on the wire to the next command; impossible on a multiplexed connection. Selection lives in the package instead.
Raw-socket sidecar per nodeWorks, kept as fallback. It re-implements TLS, auth, topology and reconnects that the library already provides.
GarnetDoes not implement CLIENT TRACKING. Redis 6+ and Valkey do.

The mechanism diagrams and mermaid sequences on this page are reused verbatim from docs/how-it-works.html.

Setup

Configuration #

Two ways to register the cache, and every option on RedisNearCacheOptions.

AddRedisNearCache has two overloads. Both register IRedisNearCache as a singleton together with the private connection, the tracking armer, and the invalidation listener it depends on.

connection string overloadcsharp
services.AddRedisNearCache("localhost:6379");

The convenience overload takes a connection string directly and sets RedisNearCacheOptions.ConnectionString.

options overloadcsharp
services.AddRedisNearCache(options =>
{
    options.ConnectionString = "localhost:6379";
    options.KeyPrefixes.Add("user:");
    options.L1MaxAge = TimeSpan.FromMinutes(5);
});

The full overload takes an Action<RedisNearCacheOptions> and lets you set every option, including Configuration (a ConfigurationOptions instance) instead of a plain connection string. Either Configuration or ConnectionString must end up set; resolving IRedisNearCache without one throws InvalidOperationException.

RedisNearCacheOptions

PropertyDefaultWhat it does
ConfigurationnullConfigurationOptions for the Redis deployment. RedisNearCache clones this and forces Protocol=Resp2, AllowAdmin=true and its own ClientName. Either this or ConnectionString must be set.
ConnectionStringnullAlternative to Configuration; parsed with ConfigurationOptions.Parse.
KeyPrefixesemptyKey prefixes that RedisNearCache will cache locally. Reads of keys outside these prefixes still go through RedisNearCache to Redis but are not stored in L1, so they cost nothing to invalidate. Empty (the default) means every key read through RedisNearCache is cached.
L1SizeLimit10_000Maximum number of entries held in L1. Least-recently-used entries are evicted beyond this.
L1MaxAge5 minutesSafety net: an L1 entry is dropped after this age even if no invalidation arrived. Protects against a missed invalidation. Set to Timeout.InfiniteTimeSpan to disable.
SerializerJsonRedisNearCacheSerializer.InstanceSerializer for values. Defaults to System.Text.Json; string and byte[] are passed through untouched.
ClientNamePrefix"rnc"Prefix for the Redis client name RedisNearCache sets on its own connections. A unique suffix is appended.
custom serializercsharp
using System.Text.Json;

services.AddRedisNearCache(options =>
{
    options.ConnectionString = "localhost:6379";
    options.Serializer = new JsonRedisNearCacheSerializer(new JsonSerializerOptions
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
    });
});

Serializer customisation

The default JsonRedisNearCacheSerializer uses System.Text.Json, with string and byte[] passed through untouched (no double-encoding, which matters for the IDistributedCache adapter). Pass your own JsonSerializerOptions to the constructor, or implement IRedisNearCacheSerializer directly for a non-JSON format.

Reference

API reference #

Every public member of RedisNearCache, one entry at a time.

GetAsync&lt;T&gt;method #

ValueTask<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)

Gets the value stored at key, serving from L1 when present and tracked. On a miss, reads the key over RedisNearCache’s tracked interactive connection, which arms server-side tracking for it, stores it in L1 if the key matches KeyPrefixes, and returns it.

Returns the deserialized value, or default when the key does not exist in Redis.
signaturecsharp
ValueTask<T?> GetAsync<T>(
    string key,
    CancellationToken cancellationToken = default);

SetAsync&lt;T&gt;method #

ValueTask SetAsync<T>(string key, T value, TimeSpan? expiry = null, CancellationToken cancellationToken = default)

Writes value to Redis through RedisNearCache’s own connection (armed with NOLOOP so the write does not invalidate itself) and evicts any L1 copy. The next GetAsync<T> re-reads and re-tracks the key. SetAsync does not populate L1 itself; write-through population of L1 is out of scope for v1.

signaturecsharp
ValueTask SetAsync<T>(
    string key,
    T value,
    TimeSpan? expiry = null,
    CancellationToken cancellationToken = default);

RemoveAsyncmethod #

ValueTask<bool> RemoveAsync(string key, CancellationToken cancellationToken = default)

Deletes the key in Redis and evicts any L1 copy.

Returns whether the key existed in Redis.
signaturecsharp
ValueTask<bool> RemoveAsync(
    string key,
    CancellationToken cancellationToken = default);

EvictLocalmethod #

void EvictLocal(string key)

Evicts the L1 copy only. Redis is not touched. Use this to force the next read to re-fetch from Redis without deleting the key.

signaturecsharp
void EvictLocal(string key);

TryGetLocal&lt;T&gt;method #

bool TryGetLocal<T>(string key, out T? value)

Returns true and the L1 value when the key is currently cached locally. Never touches Redis, so this never blocks and never counts as a hit or miss against Redis traffic.

Returns true if the key is cached in L1 right now.
signaturecsharp
bool TryGetLocal<T>(string key, out T? value);

Statisticsproperty #

RedisNearCacheStatistics Statistics { get; }

Counters for hits, misses, invalidations, flushes, re-arms and race discards. See RedisNearCacheStatistics below and Operations for what to watch.

signaturecsharp
RedisNearCacheStatistics Statistics { get; }

Readyproperty #

Task Ready { get; }

Completes once the invalidation subscription is up and the initial arming pass over every master has finished. Masters that could not be armed are retried in the background every 5 seconds; until every master is armed the cache serves every read from Redis and stores nothing locally (pass-through). Faults only if no master could be armed at all, in which case the cache stays in pass-through mode permanently.

signaturecsharp
Task Ready { get; }

RedisNearCacheStatisticsclass #

public sealed class RedisNearCacheStatistics

Six thread-safe, cumulative counters, each backed by Interlocked/Volatile. Sample them periodically and graph the rate, not the raw value.

CounterMeaning
HitsReads served from L1 without touching Redis.
MissesReads that went to Redis.
InvalidationsPer-key invalidation messages received from the server.
FlushesWhole-cache flushes (null invalidation, reconnect, re-arm).
RearmsTimes CLIENT TRACKING was (re)issued on an endpoint after the initial arm.
RaceDiscardsRedis replies discarded because an invalidation for the key arrived while the read was in flight.
ToString()csharp
cache.Statistics.ToString();
// "hits=1 misses=1 invalidations=0 flushes=0 rearms=0 raceDiscards=0"

IRedisNearCacheSerializerinterface #

public interface IRedisNearCacheSerializer

Converts values to and from the bytes stored in Redis. Implement this to plug in a non-JSON format.

signaturecsharp
byte[] Serialize<T>(T value);
T? Deserialize<T>(ReadOnlyMemory<byte> data);

JsonRedisNearCacheSerializerclass #

public sealed class JsonRedisNearCacheSerializer : IRedisNearCacheSerializer

Default serializer: System.Text.Json, with string and byte[] passed through untouched (no double-encoding). Instance is a shared instance using JsonSerializerOptions.Default; the constructor takes custom JsonSerializerOptions.

signaturecsharp
public static JsonRedisNearCacheSerializer Instance { get; }

public JsonRedisNearCacheSerializer(JsonSerializerOptions options);

AddRedisNearCache(configure)method #

IServiceCollection AddRedisNearCache(this IServiceCollection services, Action<RedisNearCacheOptions> configure)

Adds IRedisNearCache as a singleton, together with the private connection, tracking armer and invalidation listener it depends on. configure must set either RedisNearCacheOptions.Configuration or RedisNearCacheOptions.ConnectionString; otherwise resolving IRedisNearCache throws InvalidOperationException.

signaturecsharp
public static IServiceCollection AddRedisNearCache(
    this IServiceCollection services,
    Action<RedisNearCacheOptions> configure);

AddRedisNearCache(connectionString)method #

IServiceCollection AddRedisNearCache(this IServiceCollection services, string connectionString, Action<RedisNearCacheOptions>? configure = null)

Convenience overload that sets RedisNearCacheOptions.ConnectionString. An optional configure callback runs after that, so it can still set every other option.

signaturecsharp
public static IServiceCollection AddRedisNearCache(
    this IServiceCollection services,
    string connectionString,
    Action<RedisNearCacheOptions>? configure = null);
Integration

HybridCache and IDistributedCache #

An optional package that adapts IRedisNearCache to the standard distributed-cache abstractions.

shellbash
dotnet add package RedisNearCache.HybridCache

A separate package from the core library.

Program.cscsharp
services.AddRedisNearCache("localhost:6379");
services.AddRedisNearCacheHybridCache();

This registers RedisNearCacheDistributedCache as both IDistributedCache and IBufferDistributedCache, backed by the IRedisNearCache that AddRedisNearCache already registered, and then calls AddHybridCache so that Microsoft.Extensions.Caching.Hybrid.HybridCache uses RedisNearCache as its distributed tier. Use AddRedisNearCacheDistributedCache() alone if you only want the IDistributedCache/IBufferDistributedCache adapters (for session state, output caching, etc.) without HybridCache.

AddRedisNearCacheDistributedCachemethod #

IServiceCollection AddRedisNearCacheDistributedCache(this IServiceCollection services)

Registers RedisNearCacheDistributedCache as a singleton, exposed as both IDistributedCache and IBufferDistributedCache, backed by the IRedisNearCache that AddRedisNearCache must already have registered. Resolving either interface without a prior call to AddRedisNearCache throws InvalidOperationException when IRedisNearCache itself is resolved, not from this method.

signaturecsharp
public static IServiceCollection AddRedisNearCacheDistributedCache(
    this IServiceCollection services);

AddRedisNearCacheHybridCachemethod #

IServiceCollection AddRedisNearCacheHybridCache(this IServiceCollection services, Action<HybridCacheOptions>? configure = null)

Registers RedisNearCacheDistributedCache (via AddRedisNearCacheDistributedCache) and then calls AddHybridCache. Requires a prior call to AddRedisNearCache.

Why DisableLocalCache. HybridCache keeps its own in-process L1 in front of whatever IDistributedCache it is given, and that L1 knows nothing about Redis invalidations: only RedisNearCache’s own L1 is evicted when the server invalidates a key. Two L1s where only one is coherent would let HybridCache keep serving a value RedisNearCache already evicted. So unless configure overrides it, this sets HybridCacheOptions.DefaultEntryOptions.Flags to HybridCacheEntryFlags.DisableLocalCache. Every HybridCache read then goes to IDistributedCache, i.e. RedisNearCache’s tracked L1, so a hit is still served in-process. Flags merge per call: a HybridCacheEntryOptions passed to GetOrCreateAsync with Flags == null inherits the default flags, so GetOrCreateAsync(key, factory, new HybridCacheEntryOptions { Expiration = ... }) keeps the local cache disabled. If you pass explicit Flags per call, keep DisableLocalCache in them. A small LocalCacheExpiration would not have worked: a per-call Expiration silently becomes the local expiration too, which is how an earlier build of this adapter served stale values for the full entry lifetime.

The factory may run twice before the background write lands. HybridCache writes a freshly computed value to the distributed tier in the background, so a second GetOrCreateAsync issued before that write lands (well under 10 ms locally) may run the factory again. Concurrent callers are still coalesced by HybridCache’s stampede protection. That is an occasional extra factory call, never a stale read.

signaturecsharp
public static IServiceCollection AddRedisNearCacheHybridCache(
    this IServiceCollection services,
    Action<HybridCacheOptions>? configure = null);

RedisNearCacheDistributedCacheclass #

public sealed class RedisNearCacheDistributedCache : IDistributedCache, IBufferDistributedCache

Adapts IRedisNearCache to IDistributedCache and IBufferDistributedCache. Values are stored as raw byte[]/ReadOnlySequence<byte> through IRedisNearCache.GetAsync<T> and SetAsync<T> instantiated at byte[]; the default IRedisNearCacheSerializer passes byte[] through untouched, so no double-encoding happens. Every sync member (Get, Set, Remove, TryGet) simply calls its async counterpart and blocks with .GetAwaiter().GetResult(); prefer the async members.

Sliding expiration limitation. Redis TTLs, and RedisNearCache’s SetAsync, have no notion of a sliding window. DistributedCacheEntryOptions.SlidingExpiration is mapped to a plain absolute expiry equal to the sliding window, applied once at write time, and is never extended by a later read.

Refresh/RefreshAsync are no-ops for this reason: the Redis TTL set at write time is authoritative until the key expires, is overwritten, or is deleted. Callers that need a true sliding window must re-Set on each access themselves.

expiry resolutioncsharp
// DistributedCacheEntryOptions -> TimeSpan? passed to SetAsync
// 1. AbsoluteExpirationRelativeToNow, if set
// 2. AbsoluteExpiration, converted to a relative duration
// 3. SlidingExpiration, treated as an absolute expiry
// null (never expires) if none of the three is set
Resilience

Reconnects and cluster #

Tracking is per-connection state on the server. A reconnect of either connection invalidates it.

Tracking is state Redis holds per connection, so a reconnect of either of RedisNearCache’s own connections (on any node) invalidates that state.

EventWhat Redis doesWhat RedisNearCache does
Interactive connection reconnectsServer drops tracking entirely (CLIENT TRACKINGINFO reports flags=off, redirect=-1).Re-issues CLIENT TRACKING ON REDIRECT on that node, then flushes L1.
Subscriber connection reconnectsServer keeps redirecting to the now-dead old client id; invalidations are silently lost.Looks up the new subscriber client id via CLIENT LIST, re-issues CLIENT TRACKING OFF then ON REDIRECT <new id>, then flushes L1.
Cluster topology change (a master added/rediscovered)Slots may have moved to the new master.Arms the new master, then flushes L1 (ArmReason.TopologyChanged); masters already armed are left alone.
Any connection failed but not yet restoredNothing can be trusted.Flushes L1 and serves every read straight from Redis (no caching) until that node is re-armed.

Every re-arm after the very first one, and every “tracking lost” event, flushes L1 completely: anything invalidated during the gap would otherwise be lost. Statistics.Rearms counts each re-arm issued after the initial pass, and Statistics.Flushes counts every whole-cache flush (a null invalidation/FLUSHDB, a lost connection, or a re-arm). On a cluster, each master node is armed and re-armed independently: losing one node’s connections flushes L1 (nothing can be trusted while any node is unarmed) but only that node is re-armed.

Pass-through mode

Caching is only enabled while every known master is believed armed. The moment any endpoint is lost, the facade stops populating and reading L1 for every key, not only that endpoint’s keys, and flushes what is already there; reads go straight to Redis until that endpoint re-arms. This is what Ready’s doc comment calls pass-through: nothing is stored locally, so nothing can go stale from the gap.

Background retry every 5 seconds

An arm that exhausts its fast retry ladder does not give up: RedisNearCache starts a background loop, at most one per endpoint, that retries the arm every 5 seconds until it succeeds, the endpoint stops being a master, or the cache is disposed. A successful background retry is always reported with ArmReason.Recovered, which flushes L1 the same way any other re-arm does, because reads that were in flight while the node was untracked cannot be trusted.

Topology changes and EndpointRemoved

On a cluster configuration change, RedisNearCache arms any master it has never armed (ArmReason.TopologyChanged) and leaves already-armed masters alone. If a previously known master stops being a master (or drops out of the deployment) and a retry loop is still chasing it, RedisNearCache stops retrying and raises an internal EndpointRemoved event so that a node which will never come back does not keep the whole cache stuck in pass-through forever.

Replicas are ignored

Only masters are armed. A replica is never a valid arm target: CLIENT TRACKING is issued per master node, and RedisNearCache’s internal check for “is this a master we track” excludes replicas outright, whether or not the replica is currently connected.

log line: re-armtext
info: RedisNearCache.Tracking.TrackingArmer[0]
      RedisNearCache armed CLIENT TRACKING on Unspecified/localhost:6379 redirecting to client 3562 (Initial)

See Operations for the full set of log categories and what to watch.

Running it

Operations #

Logging categories, statistics to graph, detecting a lost endpoint, and sizing L1.

RedisNearCache does not register logging itself; if the host never called AddLogging, it silently uses a no-op logger. Wire up ILoggerFactory in your host to see any of this.

Logging categories

CategoryWhat it logs
RedisNearCache.Internal.RedisNearCacheConnectionOne Information line on connect: the client name and endpoints of the private multiplexer.
RedisNearCache.Tracking.TrackingArmerThe most operationally important category. Information for every successful arm/re-arm (with the endpoint, redirect client id, and ArmReason) and for ConnectionRestored/topology-change events triggering a re-arm. Warning for a retry attempt, for “no connected master to arm”, for giving up after the retry ladder is exhausted, and for a lost connection (ConnectionFailed). Debug/Trace for verification detail (CLIENT TRACKINGINFO replies, skipped replicas).
RedisNearCache.Tracking.InvalidationListenerInformation once, on subscribing. Debug for a null (FLUSHDB/FLUSHALL) invalidation. Trace for every single-key invalidation (high-volume; do not run at Trace in production unless actively diagnosing). Error if handling a message throws (should not happen).
RedisNearCache.Caching.RedisNearCacheError if the initial StartAsync (listener + armer) fails entirely, i.e. the near cache will never be coherent for at least one endpoint.

What to watch in production. Warning-level messages from TrackingArmer are the signal that something is wrong: a node that could not be armed after retries, or a connection that dropped and has not come back. A steady stream of Information-level “re-arming” messages (rather than occasional ones around deploys/restarts) suggests something is repeatedly killing the private connections.

Statistics to graph

IRedisNearCache.Statistics exposes six thread-safe counters. Sample them periodically (they are cumulative, so graph the rate/delta, not the raw value) rather than trying to log every change.

CounterWhat a change meansWhat to watch for
HitsA read served entirely from L1, no Redis call.The primary payoff metric. Compare against Misses for a hit ratio.
MissesA read that went to Redis (first read of a key, or after eviction/expiry).A sudden sustained rise (with a proportional Flushes/Rearms rise) usually means something is invalidating or flushing more than expected.
InvalidationsA per-key invalidation message received from the server.Should track your actual external write volume against tracked keys. Unexpectedly high counts on a specific key are the signal to move it out of KeyPrefixes.
FlushesA whole-cache L1 clear: a null invalidation, a lost connection, or any re-arm after the first.Should be rare in steady state. A rising rate means reconnects are happening repeatedly; check TrackingArmer warnings for why.
RearmsCLIENT TRACKING was (re)issued on an endpoint after its initial arm.Same signal as Flushes (every re-arm causes a flush) but isolates the reconnect-driven case from FLUSHDB/FLUSHALL.
RaceDiscardsA Redis reply was thrown away because an invalidation for that key arrived while the read was still in flight.Expected to be occasionally non-zero under concurrent read/write load on the same key; a value comparable to Misses suggests either very hot keys or unusually high latency to Redis.

Detecting a lost or unarmed endpoint

There is no dedicated “is everything armed” property, but you can reconstruct the current state from the pieces the library already exposes.

  • Logs. TrackingArmer logs Warning for ConnectionFailed and for giving up on the retry ladder. Alert on these.
  • Statistics. A rising Flushes/Rearms rate, or Hits dropping to near zero while Misses stays high, both indicate pass-through mode on at least one endpoint.
  • Ready. Awaiting cache.Ready only tells you whether at least one master armed during startup; it does not fault for a single node that failed to arm among several, and it does not report a later loss. Treat a completed Ready as “the cache is usable”, not “every node is armed”.
  • Direct check against Redis. CLIENT LIST filtered to RedisNearCache’s client name (ClientNamePrefix, default rnc-<guid>) and the P (pub/sub subscriber) flag tells you whether the subscriber connection currently exists; CLIENT TRACKINGINFO on the interactive connection reports whether tracking is on and what it currently redirects to.

Sizing L1

L1SizeLimit (default 10_000) counts entries, not bytes: every stored value costs a size of 1 regardless of how large it is. There is no automatic memory-based eviction, so size the limit based on (expected working-set key count) rather than a memory budget, and separately estimate memory as (number of entries you expect resident) × (average serialized value size). If your key space includes large or infrequently-reused values you would rather not hold in-process at all, use KeyPrefixes to opt only the keys worth caching into L1.

L1MaxAge (default 5 minutes) is a time-based safety net independent of size: it bounds how long a value can survive after a missed invalidation, not a sizing lever by itself. Setting it very high increases the blast radius of anything that does get missed; setting it very low pushes more traffic back to Redis even when tracking is working correctly.

CLIENT LIST filteredbash
redis-cli CLIENT LIST | grep 'name=rnc-'
Boundaries

Limitations #

  • RESP3 push tracking is not used. StackExchange.Redis 3.x collapses RESP3 to a single connection and swallows the invalidation push frames on it, so RedisNearCache always forces RESP2 for its own connection.
  • No OPTIN/OPTOUT tracking mode. CLIENT CACHING YES must be sent immediately adjacent to the next command on the wire, which is not guaranteed on a multiplexed connection. Selection of what gets cached is done in the library instead, via KeyPrefixes.
  • Garnet is not supported. Garnet does not implement CLIENT TRACKING.
  • TTL expiry is only reflected in L1 once Redis actually expires the key. Redis’s active expiry cycle, not the TTL deadline itself, is what triggers the invalidation push; there can be a gap between a key’s TTL elapsing and Redis noticing and sending the invalidation. L1MaxAge is the safety net for this and for any other missed invalidation.
  • Per-write invalidation cost on hot keys. Every write to a tracked key causes Redis to push an invalidation to every reader that had it tracked; a very hot key can mean a lot of pushes. Use KeyPrefixes to opt only the keys you actually want cached into L1, so writes to everything else cost nothing extra.
  • Ready only faults if no master could be armed at all. If some masters armed and others did not, Ready completes, but the cache stays in pass-through until every master is armed. Unarmed masters are retried in the background every 5 seconds. Watch Statistics.Hits: it stays flat while the cache is in pass-through.
  • Sentinel, ElastiCache, and Azure Managed Redis are not yet tested. The tests in this repository run against a plain standalone Redis 7.4 container and a 3-master Redis Cluster started via cluster-up.sh. Nothing in the design specifically depends on the absence of Sentinel or a managed offering, but no test exercises those configurations, so this is genuinely untested rather than a documented guarantee.
Numbers

Performance #

Quoted from bench/RedisNearCache.Bench/README.md; see that file for full methodology.

Both modes need a Redis server reachable at localhost:6379 (the repo’s docker compose container).

Zero-traffic demo

--demo seeds a key, reads it once through the near cache (a miss), then reads it 1,000 more times and checks the server’s cmdstat_get counter delta.

outputtext
1000 reads of 'demo:near-cache:zero-traffic' through the near cache (all should be L1 hits):
  cmdstat_get calls before : 263325
  cmdstat_get calls after  : 263325
  delta (Redis GETs issued): 0  (expected 0)

Write-to-eviction latency  : 1688.0 µs

Statistics: hits=1000 misses=1 invalidations=1 flushes=0 rearms=0 raceDiscards=0

All 1,000 repeated reads were served from L1 with zero Redis traffic; the one external write (from a second, separate multiplexer standing in for another client) evicted the local copy in 1688.0 µs, which is the invalidation push’s round trip: write, Redis, __redis__:invalidate, subscriber, L1 evicted.

BenchmarkDotNet suite

Run on an Apple M4 Pro against a local single-node Redis container (Toolchain=InProcessEmitToolchain, RunStrategy=Monitoring, 10 iterations).

MethodKindMeanAllocated
Plain_StringGetString173.717 µs1360 B
NearCache_HitString1.604 µs2144 B
NearCache_MissString198.658 µs9088 B
NearCache_TryGetLocalString1.325 µs2072 B
Plain_StringGetJson276.979 µs5456 B
NearCache_HitJson3.967 µs424 B
NearCache_MissJson280.596 µs6472 B
NearCache_TryGetLocalJson2.875 µs352 B

NearCache_Hit and NearCache_TryGetLocal are roughly 100x faster than a plain Redis round trip in this run, because they never leave the process. NearCache_Miss lands close to (and, for the string case, a little above) Plain_StringGet, since both do a real Redis round trip; the near cache’s overhead on the miss path is locking, in-flight bookkeeping, and deserialization. RunStrategy.Monitoring with only 10 iterations means run-to-run variance is higher than a long Throughput run would show; this is a deliberate trade-off to keep the whole suite finishing in well under a minute.

Load test

--load is a production-shaped run: many application instances, each with its own private multiplexer (two tracked connections), many concurrent readers per instance, and foreign writers whose every SET fans out an invalidation to every instance that has the key tracked. Ends with a staleness audit of every instance’s L1 against Redis. Run on an Apple M4 Pro against the repo’s local Redis 7.4 container, 2026-09-13.

Profile: 20 instances (41 server connections), 8 readers per instance reading as fast as they can, 4 writers at 2,000 foreign writes/s, 10,000 keys of 512 bytes with 80% of traffic on 500 hot keys, 30 s.

shellbash
dotnet run -c Release --project bench/RedisNearCache.Bench -- --load            # near cache
dotnet run -c Release --project bench/RedisNearCache.Bench -- --load --baseline # plain StackExchange.Redis
dotnet run -c Release --project bench/RedisNearCache.Bench -- --load --chaos    # kills a quarter of connections at T/2
# knobs: --instances 20 --readers 8 --writers 4 --writes-per-sec 2000 --keys 10000
#        --hot-keys 500 --hot-fraction 0.8 --seconds 30 --value-bytes 512

The three run modes.

Near cachePlain StackExchange.RedisNear cache + chaos
Reads41.1 M (1,369,673/s)5.7 M (190,756/s)42.3 M (1,407,049/s)
Hit ratio95.8 %n/a95.5 %
Hit latency p50 / p99 / p9990.8 / 24.8 / 4,089 µsn/a0.8 / 29.8 / 3,408 µs
Miss latency p50 / p99 / p9991,643 / 12,210 / 36,459 µs793 / 1,643 / 2,366 µs (all reads)1,370 / 8,479 / 25,319 µs
Foreign writes60,040 (2,000/s)60,035 (2,000/s)60,120 (2,000/s)
Server GET commands1.73 M (57,582/s)5.72 M (190,756/s)1.90 M (63,316/s)
Server total commands1.79 M (59,585/s)5.78 M (192,758/s)1.96 M (65,323/s)
Server network output922 MB2,839 MB994 MB
Invalidations received1.07 M (35,532/s, 17.8 per write)n/a0.83 M (27,579/s, 13.8 per write)
Race discards4,393n/a3,879
Flushes / re-arms0 / 0n/a20 / 10
Tracking keys on server10,025n/a10,051
L1 entries audited184,246n/a134,963
Stale L1 entries after quiescence0n/a0

What the numbers say:

  • With the same 20 clients, the near cache served 7.2x the reads while sending the server 3.2x fewer commands and 3x less network output. The server’s GET rate is the miss rate, 4% of reads.
  • Every foreign write reached, on average, 14 to 18 of the 20 instances as an invalidation (the 500 hot keys are tracked by almost everyone). 35k invalidations/s were absorbed with zero stale entries in the audit.
  • The chaos run killed both connections of 5 instances at T/2. Each raised TrackingLost for both connections, reconnected, and was re-armed (InteractiveRestored and SubscriptionRestored) within about 120 ms; the sampled hit ratio dipped by under a point and recovered in the next 5 s window.
  • Miss latency in the near-cache runs is worse than the baseline’s read latency (p99 12 ms vs 1.6 ms). That is a load-generator artefact: 160 readers spin at 1.4 M reads/s on a 14-core machine, so the continuation of a network read waits for a thread-pool slot. A service that does real work between reads would not see this. The hit path is unaffected (p50 0.8 µs).
  • Race discards ran at about 1 per 10,000 reads. Each costs one extra round trip on the next read; none cost correctness.
  • Observed once in five near-cache runs and not reproduced: the server closed all 40 tracked sockets at once (SocketClosed on both connection types of every instance, no CLIENT KILL issued). Every instance raised TrackingLost, reconnected and re-armed, and the audit still found 0 stale entries.
Try it

Samples #

Two runnable samples that show IRedisNearCache invalidated by something outside the process.

Both connect to localhost:6379. Start the standalone Redis container first, from the repo root.

shellbash
docker compose up -d

Bring up Redis.

MinimalApi

An ASP.NET Core minimal API (samples/MinimalApi) with:

  • GET /products/{id} — reads through IRedisNearCache.GetAsync<Product>("product:{id}"); on a miss it falls back to an in-memory ProductRepository (a fake data store with a simulated 50 ms lookup delay) and then SetAsyncs the result back into Redis.
  • PUT /products/{id} — writes a Product via SetAsync.
  • GET /stats — returns IRedisNearCache.Statistics as JSON.
  • GET /hybrid/{id} — the same read, through Microsoft.Extensions.Caching.Hybrid.HybridCache (backed by RedisNearCache via AddRedisNearCacheHybridCache()).
shellbash
dotnet run --project samples/MinimalApi --urls http://localhost:5199

Run it on a free port.

shellbash
curl http://localhost:5199/products/1
curl http://localhost:5199/products/1
curl http://localhost:5199/stats
docker exec redis-near-cache-redis redis-cli SET product:1 '{"id":1,"name":"changed"}'
curl http://localhost:5199/products/1
curl http://localhost:5199/stats

From a second terminal, curl it a couple of times, then invalidate the key from outside the app (a plain redis-cli SET, nothing that goes through this process) and curl again. The third call reflects the change because the server pushed an invalidation over CLIENT TRACKING and evicted the app’s L1 entry.

captured outputtext
=== GET /products/1 (1st) ===
{"id":1,"name":"Widget"}
HTTP 200

=== GET /products/1 (2nd) ===
{"id":1,"name":"Widget"}
HTTP 200

=== GET /stats ===
{"hits":1,"misses":1,"invalidations":0,"flushes":0,"rearms":0,"raceDiscards":0}
HTTP 200

=== external write ===
docker exec redis-near-cache-redis redis-cli SET product:1 '{"id":1,"name":"changed"}'
OK

=== GET /products/1 (3rd, after external write) ===
{"id":1,"name":"changed"}
HTTP 200

=== GET /stats (final) ===
{"hits":1,"misses":2,"invalidations":1,"flushes":0,"rearms":0,"raceDiscards":0}
HTTP 200

The 2nd call is an L1 hit (hits goes from 0 to 1, misses stays at 1). After the external redis-cli SET, the 3rd call already returns "changed": invalidations goes from 0 to 1 (the server pushed one), and the GET after it counts as a miss again because the near cache had to go back to Redis to re-populate L1 for that key. Note: because SetAsync (used by PUT /products/{id} and by the repository-fallback path on GET /products/{id}) evicts L1 rather than populating it, the read immediately after a write is always a miss; the next read after that is what gets served from L1.

Worker

A BackgroundService (samples/Worker) that polls the key config:feature-flags through the near cache every 500 ms and logs whether that tick was an L1 hit or a Redis miss, plus a full Statistics snapshot every 5 seconds.

shellbash
dotnet run --project samples/Worker

Run it.

shellbash
docker exec redis-near-cache-redis redis-cli SET config:feature-flags '{"beta":true}'

From a second terminal, change the watched key at any point and watch the very next tick pick it up.

captured outputtext
info: RedisNearCache.Tracking.TrackingArmer[0]
      RedisNearCache armed CLIENT TRACKING on Unspecified/localhost:6379 redirecting to client 3562 (Initial)
info: RedisNearCache.Samples.Worker.ConfigPollingWorker[0]
      MISS config:feature-flags = <null>  (cumulative hits=0 misses=1)
      ... (17 more MISS ticks while the key does not exist yet) ...
info: RedisNearCache.Samples.Worker.ConfigPollingWorker[0]
      Statistics: hits=0 misses=11 invalidations=0 flushes=0 rearms=0 raceDiscards=0
      ... (redis-cli SET config:feature-flags '{"beta":true}' run here) ...
info: RedisNearCache.Samples.Worker.ConfigPollingWorker[0]
      MISS config:feature-flags = {"beta":true}  (cumulative hits=0 misses=19)
info: RedisNearCache.Samples.Worker.ConfigPollingWorker[0]
      hit  config:feature-flags = {"beta":true}  (cumulative hits=1 misses=19)
info: RedisNearCache.Samples.Worker.ConfigPollingWorker[0]
      Statistics: hits=3 misses=19 invalidations=1 flushes=0 rearms=0 raceDiscards=0

config:feature-flags did not exist yet, so every tick is a miss (RedisNearCache never stores a null value in L1, so a missing key stays a miss on every poll) until the external redis-cli SET partway through. The first tick after the SET (misses=19) is still a miss, the read that goes to Redis and populates L1, but it already returns the new value. Every tick after that is a hit until the key is written again from outside.

Questions

FAQ #

Do my writers need to change? #

No. Any client, in any language, doing a plain SET, DEL, MSET, or a redis-cli command by hand causes Redis itself to push an invalidation to every connection tracking that key. RedisNearCache does not require writers to publish anything or use the same library.

Can I use my existing IConnectionMultiplexer? #

Your existing multiplexer is never touched, reconfigured, or depended on. RedisNearCache opens its own private multiplexer (cloned connection settings, forced to RESP2 with admin mode). Your own connection can stay on whatever protocol and configuration it already uses.

Why does RedisNearCache need its own connection instead of using mine? #

Two reasons: CLIENT TRACKING and CLIENT LIST require AllowAdmin=true, which most applications do not (and should not) set on their main connection; and every read issued on a tracked connection is tracked by Redis, whether or not it is something you actually want cached. Giving RedisNearCache its own connection means only cache reads are tracked, and your main connection’s admin surface is untouched.

Why RESP2 instead of RESP3? #

StackExchange.Redis 3.x is RESP3 by default, but under RESP3 the library collapses to a single connection per node and swallows the invalidate push frames internally. The spike measured this directly: the server reports tracking as on, but zero invalidation messages ever reach the application. RESP2 is the only protocol under which StackExchange.Redis opens a separate subscriber connection, which is what RedisNearCache uses as the REDIRECT target.

Why does it need admin mode (AllowAdmin=true)? #

CLIENT TRACKING ON/OFF, CLIENT TRACKINGINFO, and CLIENT LIST are all gated behind admin mode in StackExchange.Redis; without it they throw RedisCommandException: not available unless admin mode is enabled. This only applies to RedisNearCache’s own private connection, not yours.

What if Redis restarts, or my connection drops? #

RedisNearCache re-arms automatically. An interactive-connection reconnect means the server dropped tracking entirely; a subscriber-connection reconnect means the server is still redirecting to a now-dead client id and would otherwise silently lose every invalidation. Both cases are handled: RedisNearCache re-issues CLIENT TRACKING (finding the new subscriber id via CLIENT LIST when needed) and flushes L1. While any node is down and not yet re-armed, every read goes straight to Redis and nothing is cached (pass-through), so the cache can never serve something stale from that window. This is exercised directly by the integration tests, including killing both connections back to back (Chaos/BothConnectionsKilledTests) and restarting a whole cluster node (Chaos/NodeRestartClusterTests).

What if Redis crashes and does not come back? #

IRedisNearCache.Ready only faults if no master could be armed at all after the retry ladder is exhausted; in that case the cache stays permanently in pass-through rather than throwing on every call. If Redis is unreachable, the reads themselves will fail the same way they would through a plain StackExchange.Redis connection. RedisNearCache does not add its own resilience layer on top of that.

Does it work with Redis Cluster? #

Yes. Tracking is per node, so RedisNearCache’s armer repeats the arm/re-arm dance independently on every connected master, using that node’s own subscriber connection as the redirect target. A lost or restarted node re-arms on its own; the other nodes are unaffected. This is covered by tests/RedisNearCache.Tests/ClusterPerNodeInvalidationTests.cs, ClusterKillOneNodeInteractiveRearmsTests.cs, and Chaos/NodeRestartClusterTests.cs.

Does it work with Sentinel, ElastiCache, or Azure Managed Redis? #

Not yet tested. The tests in this repository run against a plain standalone Redis 7.4 container and a 3-master Redis Cluster started via cluster-up.sh. Nothing in the design specifically depends on the absence of Sentinel or a managed offering (it works through StackExchange.Redis’s own topology handling and per-master CLIENT TRACKING), but no test exercises those configurations, so this is genuinely untested rather than a documented guarantee.

How much memory does L1 use? #

L1 is a Microsoft.Extensions.Caching.Memory.MemoryCache where every entry costs a size of 1 against RedisNearCacheOptions.L1SizeLimit (default 10_000). There is no accounting for the byte size of values, so memory use is roughly (number of cached entries) × (average serialized value size), bounded above by L1SizeLimit entries. Use KeyPrefixes to limit which keys are eligible for L1 at all if you have large or very numerous values you do not want held locally.

What happens to a key's TTL? #

RedisNearCache does not track expiry deadlines itself. When Redis’s own active expiry cycle actually removes an expired key, that is a normal deletion from Redis’s point of view and it is invalidated like any other write. Because active expiry can lag the TTL deadline, there can be a small window where L1 still holds a value whose TTL has technically passed. L1MaxAge (default 5 minutes) is the safety net for this and for any other missed invalidation.

Can I invalidate a key without touching Redis? #

Yes, EvictLocal(key) removes only the local L1 copy without going to Redis. This is separate from RemoveAsync, which deletes the key in Redis too.

Does SetAsync populate L1 immediately? #

No. SetAsync writes to Redis and evicts any existing L1 copy of the key; the next GetAsync<T> re-reads it from Redis and re-tracks it. Write-through population of L1 is explicitly out of scope for v1.