Skip to main content

Client-side caching

Respire can cache eligible Redis reads in-process. Redis tracks the keys Respire reads (CLIENT TRACKING) and pushes an invalidation when one changes. Respire evicts the entry, and the next read fetches the new value. A hit skips the network round trip, and your code keeps the same typed API.

This is Redis's server-assisted cache, not an application object cache. Application keyspace notifications are a separate Pub/Sub feature.

Enable it​

await using var redis = await RespireClient.ConnectAsync(new RespireOptions
{
Endpoints = { new RespireEndpoint("localhost") },
ClientSideCache = new(),
});
  • The cache requires RESP3. Connection setup fails if Redis cannot negotiate RESP3 or enable CLIENT TRACKING.
  • With ClientSideCache = null (the default), no cache, tracking handshake, or invalidation handler is created.
  • Typed APIs, catalog ExecuteAsync calls, interpolated commands, and GetLeaseAsync use the cache transparently.
  • Typed GET and MGET keep per-key entries, so an MGET combines local hits with one Redis request for the misses. Other replies are cached by their exact command and arguments, including argument order and binary arguments. To let HMGET reuse HGET field entries, see Partial hash reads.
  • Missing keys are cached too.
  • Cached replies are immutable and converted for each call. Serializers run on every hit, and GetBytesAsync still returns a caller-owned array.
  • Each client has one cache, shared by all of its WithKeyPrefix views.
  • String reads also keep their decoded text, so later GetStringAsync hits skip decoding. That text counts against MaxSizeBytes.

Cached commands​

A read is cached only when Redis marks it cacheable and Respire can name every key it depends on:

GroupCached reads
StringsGET, MGET, STRLEN, GETRANGE, SUBSTR, DIGEST, LCS
KeysEXISTS, EXPIRETIME, PEXPIRETIME, TYPE, OBJECT ENCODING, MEMORY USAGE ... SAMPLES 0, SORT_RO without BY or GET patterns
HashesHGET, HMGET, HGETALL, HEXISTS, HLEN, HSTRLEN, HKEYS, HVALS, HEXPIRETIME, HPEXPIRETIME
ListsLINDEX, LLEN, LPOS, LRANGE
SetsSCARD, SDIFF, SINTER, SINTERCARD, SISMEMBER, SMEMBERS, SMISMEMBER, SUNION, SDIFFCARD, SUNIONCARD
Sorted setsZCARD, ZCOUNT, ZDIFF, ZINTER, ZINTERCARD, ZLEXCOUNT, ZMSCORE, ZRANGE and the legacy range aliases, ZRANK, ZREVRANK, ZSCORE, ZUNION
StreamsXLEN, XRANGE, XREVRANGE, summary-form XPENDING, XINFO STREAM, XINFO GROUPS
BitmapsGETBIT, BITCOUNT, BITPOS, BITFIELD_RO
GeospatialGEODIST, GEOHASH, GEOPOS, GEOSEARCH (without COUNT ... ANY), GEORADIUS_RO, GEORADIUSBYMEMBER_RO
Arraysevery read-only command, including ARSCAN
JSONJSON.ARRINDEX, JSON.ARRLEN, JSON.GET, JSON.MGET, JSON.OBJKEYS, JSON.OBJLEN, JSON.RESP, JSON.STRLEN, JSON.TYPE
Vector setsVCARD, VDIM, VEMB, VGETATTR, VINFO, VISMEMBER, VLINKS, VRANGE, VSIM

Everything else goes to Redis:

  • Time-varying replies: DUMP, relative TTLs (TTL, PTTL), cursor scans (SCAN, HSCAN, SSCAN, ZSCAN), random commands, and detailed XPENDING.
  • Reads with unknowable or arbitrary results: SORT_RO with BY or GET, GEOSEARCH with COUNT ... ANY, and sampled MEMORY USAGE.
  • TOUCH, probabilistic structures, blocking reads, scripts and functions, time series, Search, and unkeyed server state.
  • Batches and transactions, which never consult the local cache.

While caching is enabled, HELLO, RESET, SELECT, CLIENT CACHING, and CLIENT TRACKING throw NotSupportedException, because they would break coherence.

Writes evict the keys they change before they are sent, and reads that overlap a write are not cached. When Respire cannot name the affected keys, it flushes the whole local cache. This applies to unknown raw commands, scripts, cluster-wide mutations, blocking commands, batches, transactions, and time-series writes that can update compaction destinations.

Options​

Every option has a bounded default, so new() is a complete configuration:

OptionDefaultMeaning
MaxEntries10_000Maximum resident entries. Must be at least 1.
MaxSizeBytes64 MiBApproximate maximum bytes owned by cached replies. Must be at least 1.
LocalExpiration5 minutesMaximum local lifetime of an entry, independent of the key's Redis TTL. null keeps entries until invalidated or evicted; otherwise at least 1 ms.
KeyPrefixesempty (all keys)Physical key prefixes eligible for caching.
TrackingModeOptInHow Redis tracks cached keys: selected reads (OptIn), reads by default (OptOut), or by prefix (Broadcast).
NoLoopfalseSuppress invalidations caused by the tracking connection's own writes. Local mutation eviction remains enabled.
CoalesceConcurrentMissesfalseShare concurrent identical misses and GetOrSetAsync factories (stampede protection).
ReuseHashFieldsfalseServe HMGET fields from cached HGET entries.
ClientSideCache = new()
{
KeyPrefixes = ["product:", "price:"],
MaxEntries = 50_000,
LocalExpiration = TimeSpan.FromMinutes(1),
CoalesceConcurrentMisses = true,
},
  • Invalid values throw RespireConfigurationException when the client is created.
  • MaxEntries and MaxSizeBytes are both hard limits: whichever is reached first triggers eviction.
  • The size estimate covers reply payloads, command arguments, dependency keys, and a fixed per-entry overhead. A response that would exceed the limit is returned without being cached.
  • LocalExpiration is checked lazily on lookup; no timer runs.

Choose what gets cached​

By default every eligible read is cached. Set KeyPrefixes to cache only hot, read-mostly keys such as catalogs or configuration. Reads of other keys go straight to Redis and use no cache capacity. In OptIn mode they are also sent without CLIENT CACHING YES, so Redis does not track them. In OptOut mode KeyPrefixes controls local admission only: Redis can track uncovered reads, and other reads that Respire does not cache. Use WithoutClientCache() when a read must explicitly opt out of server tracking as well as local caching.

Prefix rules:

  • Prefixes are literal bytes, not Redis glob patterns; * and ? match only themselves. Pass binary prefixes as RespireKey values. The options snapshot both the list and its bytes.
  • Prefixes match physical wire keys. A WithKeyPrefix("tenant:") view does not add its prefix, so KeyPrefixes = ["tenant:products:"] covers products:42 read through that view, but not an unprefixed products:42 call.
  • Duplicate and overlapping prefixes are rejected before connecting. One empty prefix covers everything and cannot be combined with another prefix.
  • A cached multi-key read requires every key it depends on to be covered. Hash fields inherit their hash key's coverage. Invalidation subscriptions require a covered key.
  • In Broadcast mode the prefixes are also sent to Redis.

In OptIn mode, MGET with a mix of covered and uncovered keys behaves as follows:

  • A typed MGET, or a raw MGET with CoalesceConcurrentMisses = true, sends its misses as one tracked command to stay atomic. Redis then tracks every key it reads, so uncovered keys can generate invalidation pushes, although they never enter the cache. Read uncovered keys in a separate MGET to avoid this.
  • A raw ExecuteAsync(MGET, ...) with CoalesceConcurrentMisses = false is cached as one exact query. If any key is uncovered, the reply is not cached and the command is sent without CLIENT CACHING YES.

Bypass the cache for one read​

WithoutClientCache() returns a view whose reads always go to Redis. Use it when a code path must see the latest server value, for example right before a conditional write:

var fresh = redis.WithoutClientCache();
var stock = await fresh.Strings.GetAsync<int>("product:42:stock");
  • The view shares the client's connections and keeps its key prefix and read routing.
  • Writes through the view still invalidate cached entries; its reads never populate the cache.
  • On a tracking connection in OptOut mode, a bypass read sends CLIENT CACHING NO immediately before the read. This prevents that command from adding tracking entries; it does not remove tracking established by an earlier read. Previously cached keys still invalidate normally. Connections with tracking disabled, such as replica and dedicated connections, need no prelude.
  • In Broadcast mode, bypassing a read does not change the connection's prefix subscriptions.
  • GetOrSetAsync on the view reads Redis directly and never shares factories.
  • When client-side caching is disabled, WithoutClientCache() returns the same client.

Concurrent misses​

Set CoalesceConcurrentMisses = true to share concurrent misses for the same command and byte-identical arguments within a client. It covers typed GET variants, identical ordered MGET miss lists, and all eligible deterministic query reads.

  • Typed and raw calls can join the same request. Each caller still gets its own conversion and independently owned results and leases.
  • Typed and raw GET/MGET share per-key entries, so either can populate hits for both.
  • Prefix views share by resolved wire key. Separate clients, databases, and Cluster routing contexts never share work.
  • GET and MGET never share, and partially overlapping MGET lists are not split.

Cancellation and failures:

  • Canceling one caller does not cancel the others. When the last caller cancels, Respire cancels the shared request. A command Redis has already accepted may still execute.
  • Each caller's token bounds only that caller's wait. The shared request keeps its original CommandTimeout deadline; joining does not restart it.
  • Server and transport failures reach every remaining caller, and a later call can retry. A shared request is never replayed after a transport failure.
  • Client disposal cancels all shared work.

Invalidations, Clear(), and connection continuity changes stop new callers from joining older requests. Callers already waiting may get the original result, but it is not cached. An invalidation stops joining for all pending requests, including those for unrelated keys. Hit and miss counters count callers, not wire requests. The counter respire.client_cache.shared_read.retirements shows how often this churn stops sharing.

Sharing adds bookkeeping and result copies on every miss; hits are unaffected. Keep it off for low-contention workloads.

Partial hash reads​

Set ReuseHashFields = true to serve HMGET fields from cached HGET entries. By default each HMGET is cached as one exact query.

  • Hashes.GetManyAsync and raw HMGET look up each field in the same entry as HGET. Cached fields return locally; all missing fields go in one HMGET. An all-hit request sends no command.
  • Results keep the requested order, duplicate fields, and nulls for absent hashes or fields.
  • Typed calls apply WithKeyPrefix; raw commands must pass physical keys.
  • A Redis invalidation or local write of the hash evicts every cached field of that hash. A malformed reply is rejected before any field is cached.
  • With CoalesceConcurrentMisses also on, concurrent requests for the same hash with identical ordered missing fields share one HMGET. [a, b] and [b, a] do not share.
  • Hashes outside KeyPrefixes bypass per-field reuse.
  • Hit and miss statistics count field lookups, including repeated fields.
  • Like a cached MGET, a result may combine values cached at different times. Use an uncached transaction when you need an atomic snapshot.

Field reuse costs extra entries, lookups, and allocations in exchange for transferring fewer values. Small local responses can be slower, even with partial hits. Benchmark your payloads, field overlap, and network before enabling it.

Populate missing values with a factory​

GetOrSetAsync<T>(key, factory, expiry, cancellationToken) combines a tracked read with a conditional cache-aside write. It requires ClientSideCache and Redis 7.0 or later for SET NX GET. Hits follow the normal cache, serialization, and key-prefix rules.

using Respire;

await using var client = await RespireClient.ConnectAsync(new RespireOptions
{
Endpoints = [new("localhost", 6379)],
ClientSideCache = new() { CoalesceConcurrentMisses = true },
});

var product = await client.GetOrSetAsync<Product>(
"product:42", LoadProductAsync, TimeSpan.FromMinutes(5));

static ValueTask<Product?> LoadProductAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return ValueTask.FromResult<Product?>(new Product(42, "Coffee"));
}

public sealed record Product(int Id, string Name);

On a miss, the factory runs and SET NX GET stores its value only if the key is still absent. Otherwise it returns the value another writer stored, and that winner keeps its TTL. If the key was created and deleted before the SET runs, the computed value is stored.

  • No lock or exactly-once guarantee. Another writer can change the key right afterward.
  • No capability check. On an unsupported server or proxy, the factory can run before the write fails. Keep factories safe to run without a following write.
  • GetOrSetAsync is a required IRespireClient member. Custom implementations and decorators must implement or forward it; omitting it is a compile error.
  • Expiry uses RespireExpiry: pass a TimeSpan for a relative TTL or a DateTimeOffset / RespireExpiry.At(instant) for an absolute deadline. Relative TTLs must be at least 1 ms, are truncated to whole milliseconds, and start at the successful server write. Absolute deadlines use PXAT and must be after the Unix epoch; Redis applies a past deadline immediately, even if the factory has just produced the returned value. None, Keep, and Persist are rejected. Hits and failed conditional writes do not extend expiry. LocalExpiration is separate. Existing positional TimeSpan calls still compile; rename named ttl: arguments to expiry:.
  • Null: a factory that returns null does not write, and a later call can run it again. A stored 0, empty string, or serialized JSON null counts as present.
  • Each caller gets its own deserialized result, never the factory's object. Use the same serialization for every writer of a key.
  • The write invalidates other tracking clients but is not inserted into the local cache. The next GET registers tracking and populates it.

With a command hook, each caller receives one GETORSET scope before cache lookup or factory execution, including hits and cache-bypass views. Completion includes serialization, insertion and final conversion; application commands inside the factory have independent scopes.

Factory sharing follows CoalesceConcurrentMisses:

  • Enabled: calls on the same client with the same physical key, generic type, and expiry form/value share the first factory, so different callbacks with that identity must be interchangeable. Prefix views share their root client's factories; different clients do not. An invalidation stops new callers from joining, so a second factory can start while the first still runs.
  • Disabled: each missed call runs its own factory.
  • A factory must not await GetOrSetAsync for the same key, type, and expiry.

Cancellation and errors:

  • Each token bounds that caller's wait. With sharing, the factory's token is canceled only when the last caller leaves or the client is disposed. Without sharing, the factory gets the caller's token.
  • A result that arrives after cancellation is not written, but a write Redis already accepted may still execute.
  • CommandTimeout bounds Redis operations, not your factory. Pass a token with a timeout if you need one.
  • Factory, serialization, and Redis errors propagate without retrying the factory or replaying the write.

Why this is different​

StackExchange.Redis 3.x supports RESP3 and keyspace notifications but has no built-in server-assisted local cache (its 4.0 previews add an opt-in one). Its keyspace-notification documentation presents notifications as a building block for your own invalidation strategy. That needs Redis server configuration plus application-owned storage, subscriptions, node coverage, bounds, command eligibility, and race handling.

Respire uses CLIENT TRACKING directly; no notify-keyspace-events setting is needed. Redis's client-side caching support table does not list StackExchange.Redis.

Measured impact​

These results compare a local Respire cache hit with an ordinary StackExchange.Redis server read, showing the value of skipping the round trip. Uncached Respire and StackExchange.Redis reads were statistically equivalent in the same net10 run.

net10 operationStackExchange.Redis server readRespire client-cache hitHit latency
GET, present186.5 μs151.5 ns0.081%
GET, missing185.9 μs129.5 ns0.070%
HGET186.8 μs466.5 ns0.250%
EXISTS185.6 μs387.3 ns0.209%

BenchmarkDotNet used Redis 8.10, two launches, three warmups, and three measured iterations on a GitHub-hosted Linux runner. See the official net8/net10 run and benchmark source.

Invalidation flow​

read miss → Redis response → local entry
key changes → RESP3 invalidation push → local eviction
next read → Redis response → refreshed local entry

In OptIn mode, Redis tracks only the misses Respire sends with CLIENT CACHING YES. If tracking continuity is lost, Respire clears the affected cache state rather than trust entries whose invalidations may have been missed.

Opt-out tracking and local writes​

Choose OptOut when most reads should be tracked. Each caching connection sends CLIENT TRACKING ON OPTOUT at setup and reconnect; ordinary cache misses need no per-read CLIENT CACHING YES command. Redis still stores per-key tracking state, including for reads that are not admitted to the local cache. On tracking connections, WithoutClientCache() sends CLIENT CACHING NO with its reads to opt out explicitly. For bypass transactions containing reads on tracking connections, the prelude is paired with MULTI and suppresses tracking for the whole transaction. Empty and write-only transactions need no caching prelude or extra reply slot. Watched transactions use dedicated connections without tracking and need no caching prelude.

await using var redis = await RespireClient.ConnectAsync(new RespireOptions
{
Endpoints = { new RespireEndpoint("localhost") },
ClientSideCache = new()
{
TrackingMode = RespireClientTrackingMode.OptOut,
NoLoop = true,
},
});

NoLoop adds NOLOOP to the tracking handshake in any mode. Redis then suppresses invalidations caused by writes on that same physical connection. It does not suppress writes from another connection in the same client pool, or from another client.

Local writes must update or evict cached values themselves when NoLoop is enabled. Respire already evicts affected entries and prevents overlapping reads from being cached, including writes through WithoutClientCache(); these protections stay enabled. NOLOOP reduces redundant server pushes without replacing local eviction. Tracking mode and NoLoop survive reconnects and apply to discovered Sentinel and Cluster data connections. Create a new client to change them.

Broadcast tracking​

Choose Broadcast to receive invalidations for a known keyspace instead of having Redis register each key you read:

await using var redis = await RespireClient.ConnectAsync(new RespireOptions
{
Endpoints = { new RespireEndpoint("localhost") },
ClientSideCache = new()
{
TrackingMode = RespireClientTrackingMode.Broadcast,
KeyPrefixes = ["tenant:products:", "tenant:prices:"],
},
});
  • Each caching connection sends CLIENT TRACKING ON BCAST PREFIX ... with KeyPrefixes at setup and on reconnect. Empty KeyPrefixes sends plain BCAST, covering every key.
  • Reads omit CLIENT CACHING YES. Coverage follows Choose what gets cached.
  • RESP3 is still required. Standalone, Sentinel, and Redis/Valkey Cluster connections all use the selected mode. Blocking commands, batches, and transactions still bypass the cache.
  • To change the mode or prefixes, create a new client.
  • This client receives invalidations for every matching write, even for keys it never read. More prefixes add server work; broad prefixes add network and eviction traffic.

MaxInflightCommands must allow the commands Respire sends together for one cached read:

ModeNon-ClusterCluster
Broadcast12 (ASKING + read)
OptIn2 (CLIENT CACHING YES + read)3
OptOut1 for ordinary reads; 2 for bypass reads (CLIENT CACHING NO + read)2 for ordinary reads; 3 for bypass reads

Because bypass reads remain available in OptOut mode, its minimum MaxInflightCommands is 2 outside Cluster and 3 in Cluster, the same as OptIn.

See CLIENT TRACKING and the broadcast reference.

Observe invalidations​

SubscribeInvalidations wakes a local coordinator when a physical key may need re-reading. Subscribe before the first read so an invalidation cannot slip between the read and registration:

var wakeUps = System.Threading.Channels.Channel.CreateBounded<bool>(1);
using var observation = redis.ClientSideCache!.SubscribeInvalidations(
"jobs:ready", _ => wakeUps.Writer.TryWrite(true), cancellationToken);

while (!cancellationToken.IsCancellationRequested)
{
var state = await redis.GetStringAsync("jobs:ready", cancellationToken);
ProcessCurrentState(state);
await wakeUps.Reader.ReadAsync(cancellationToken);
}
  • The callback receives a RespireClientCacheInvalidation whose Key is the physical wire key. Binary keys are copied. A WithKeyPrefix("tenant:") view must subscribe to tenant:jobs:ready, not jobs:ready.
  • Registration sends no command and registers no server tracking. In OptIn mode you get notifications only after an eligible cached read, so read again after each wake-up. OptOut also requires a read to register a key; a bypass read does not register it.
  • A key outside KeyPrefixes throws ArgumentException.
  • The subscription survives reconnects but cannot recover events lost during a disconnect.
  • Reasons is a RespireClientCacheInvalidationReason flags value: ServerInvalidation, LocalMutation, ExplicitClear, ContinuityLost. Local writes can notify before they are sent and again after they complete, even if the value did not change.
  • Redis-wide invalidations, Clear(), writes with unknown keys, and continuity loss wake every subscription. TTL expiry and capacity eviction do not notify.

Delivery rules:

  • Callbacks run on the ThreadPool, one at a time per subscription, never inline on the socket reader. The registration's ExecutionContext does not flow.
  • A slow observer has at most one pending notification, with reason flags combined. Intermediate events, counts, and ordering across subscriptions are not preserved.
  • A throwing callback does not stop eviction or other observers. LastObserverException keeps the latest exception. Catch and log inside the callback if you need every failure. Use synchronous callbacks; async void exceptions cannot be captured.
  • A global flush schedules one callback per subscription, and registering or removing one costs time proportional to the number of observed keys. Keep subscriptions long-lived and bounded.

Dispose the returned IRespireClientCacheInvalidationSubscription or cancel its token to stop delivery; client disposal stops all subscriptions. Disposal does not wait for a running callback, so a callback may dispose its own subscription or client.

These signals mean recheck current state, not "a write occurred". Do not use them as a durable event log, distributed lock, or cross-process correctness guarantee. For correctness-critical coordination, combine authoritative Redis commands with cancellation and periodic reconciliation; an undetected network partition can delay notifications indefinitely.

SubscribeInvalidations is a required IRespireClientSideCache member. Custom implementations and decorators must implement or forward it; omitting it is a compile error.

ASP.NET Core registration​

Respire.DependencyInjection adds UseClientSideCaching to its options builder. Pass a RespireClientSideCacheOptions to override the defaults:

builder.Services.AddRespire(options =>
{
options.Endpoints.Add(new RespireEndpoint("redis.internal"));
options.UseClientSideCaching();
});

RespireDistributedCache reads through Lua scripts to keep Microsoft-compatible sliding expiration, so it never uses this cache. Enable client-side caching on a separately registered IRespireClient for direct reads. For an application-level object cache, use HybridCache.

Diagnostics​

var cache = redis.ClientSideCache!;
var statistics = cache.GetStatistics();

Console.WriteLine($"{statistics.Hits} hits; {statistics.SizeBytes} bytes");
cache.Clear();
  • ClientSideCache is null when caching is disabled.
  • GetStatistics() returns a point-in-time RespireClientSideCacheStatistics: hits, misses, invalidations, evictions, continuity flushes, resident entries (Count), and approximate bytes (SizeBytes).
  • Clear() also rejects older reads still in flight, so they cannot refill the cache.

The Respire OpenTelemetry meter emits:

InstrumentMeaning
redis.client.csc.requestsCache lookups; redis.client.csc.result is hit or miss.
redis.client.csc.evictionsResponses removed; redis.client.csc.reason is full, ttl, or invalidation.
respire.client_cache.invalidationsKey or broadcast invalidations received.
respire.client_cache.continuity_flushesFlushes caused by connection or topology uncertainty.
respire.client_cache.shared_read.retirementsShared misses new callers could no longer join (see Concurrent misses).
  • redis.client.csc.* counters are recorded only when RespireMetricGroups.ClientSideCaching is enabled with RespireMetrics.Configure; it is not in RespireMetricGroups.Default. See Observability.
  • They carry db.system.name=redis and redis.client.library=Respire:<version>.
  • One invalidated key can remove several cached responses or none.
  • Local writes, Clear(), and continuity flushes do not increment redis.client.csc.evictions, but GetStatistics().Evictions counts entries removed by flushes.

Consistency boundary​

  • Respire rejects a stale reply when an invalidation races its insertion. A read that depends on several keys, or several fields of one hash, is rejected if any of them changes while in flight; a write to an unrelated key does not affect it.
  • Reads that start while a local write is in flight are not cached, even if they finish after it.
  • Respire flushes the cache after writes with unknown keys and on detected connection loss, reconnect, Cluster MOVED/ASK redirects, and topology changes. In OptIn mode an ASK retry sends ASKING, CLIENT CACHING YES, and the read together, so the migration target tracks the key.
  • Like every server-assisted client cache, Respire cannot see invalidations across an undetected network partition. LocalExpiration adds a staleness bound but does not replace tracking. For bounded failure detection, configure TcpKeepAliveTime or ConnectionIdleReadTimeout, and keep a finite LocalExpiration.