Skip to main content

Client-side caching

Respire can cache eligible Redis reads in-process while Redis invalidation pushes keep entries coherent across clients. Repeated reads skip command encoding, socket I/O, Redis execution, reply parsing, and network latency while the application keeps using the same typed API.

This is Redis's server-assisted cache—not a second application caching abstraction. Redis tracks the keys Respire reads, pushes only invalidations when those keys change, and Respire evicts them. The next caller refreshes lazily; Redis never pushes replacement values.

Application keyspace notifications are a separate Pub/Sub feature. They do not configure or replace this CLIENT TRACKING cache.

Enable it​

Enable caching once on the client:

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

Existing typed APIs and catalog ExecuteAsync calls then use the cache transparently. This covers deterministic keyed reads across strings, keys, hashes, lists, sets, sorted sets, streams, bitmaps, geospatial indexes, Redis arrays, JSON, and vector sets. Typed GET and MGET keep optimized per-key entries and partial-hit behavior. Opting into ReuseHashFields lets HMGET reuse individual HGET field entries; other replies use exact command-and-argument identities.

Missing keys are cached too. Replies are deep-owned internally and converted for each call, so enabling caching does not introduce shared mutable objects.

Options​

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

OptionDefaultPurpose
MaxEntries10_000Maximum resident entries.
MaxSizeBytes64 MiBApproximate maximum bytes owned by cached replies.
LocalExpiration5 minutesMaximum local lifetime of an entry, independent of the key's Redis TTL. null keeps entries until invalidated or evicted.
KeyPrefixesempty (all keys)Physical key prefixes eligible for caching.
TrackingModeOptInHow Redis tracks cached keys: per read (OptIn) or by prefix (Broadcast).
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,
},

Choose what gets cached​

By default every eligible read is cached. Set KeyPrefixes to cache only the keys that benefit: hot, read-mostly data such as catalogs or configuration. Reads of other keys go straight to Redis and never compete for cache capacity. In OptIn mode they are also sent without CLIENT CACHING YES, so Redis does not track them, with one exception: the per-key MGET path used by typed calls (and raw calls with CoalesceConcurrentMisses = true) sends mixed covered/uncovered misses as one tracked command, so Redis tracks every key it reads. Those uncovered keys can generate invalidation pushes but never enter the local cache. Respire does not split the command, which keeps MGET atomic. Read uncovered keys in a separate MGET to avoid their tracking. This works in both tracking modes; in Broadcast mode the same prefixes are also sent to Redis. With the default CoalesceConcurrentMisses = false, raw ExecuteAsync(MGET, ...) uses exact-query caching instead: if any key is uncovered, the whole reply is uncached and the command is sent without CLIENT CACHING YES.

Prefixes are literal bytes, not Redis glob patterns; *, ?, NUL, and non-UTF-8 bytes retain their literal meaning. Pass binary prefixes as RespireKey values. Options snapshot both the list and its storage. Prefixes identify 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. Duplicates and overlapping prefixes are rejected before connecting; one empty prefix covers everything and therefore cannot accompany another prefix.

Mixed per-key MGET calls retain covered hits and fetch misses together, caching only covered keys (in OptIn mode that fetch is tracked as described above). A cached multi-key projection requires every dependency to be covered. Hash fields inherit their physical hash key's coverage. Invalidation subscriptions require a covered key.

Bypass the cache for one read​

WithoutClientCache() returns a view whose reads always go to Redis. Use it when a code path must observe the latest server value, for example immediately 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, and its reads never populate the cache. 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 ClientSideCache.CoalesceConcurrentMisses = true to share concurrent misses for the same command and byte-for-byte arguments within a client. Sharing is opt-in; the default is false. This covers typed GET variants, identical ordered MGET miss lists, and all eligible deterministic query reads. Typed and raw calls can join the same wire command; each caller still performs its own conversion and receives independently owned results and leases. With sharing enabled, typed and raw GET/MGET use the same per-key entries, so either caller can populate later hits for both APIs. Raw MGET also reuses the typed partial-hit path. Binary keys and arguments are snapshotted. Prefix views use resolved wire keys; separate clients, databases, and Cluster routing contexts never share work. GET and MGET are different identities, and partially overlapping MGET lists are not split into individual GET requests.

Canceling one caller does not cancel callers still waiting for the shared request. When the last caller cancels, Respire retires and cancels that request. An already accepted command may still execute on Redis; its reply is drained in protocol order. The next caller can start a new request. Each caller's cancellation token bounds only that caller's wait. The physical request keeps its original CommandTimeout deadline; joining an existing request does not restart that deadline. Client disposal cancels all shared work, including reads retired by an earlier invalidation. Server and transport failures reach every remaining caller, retire the shared request, and allow a later call to retry. Sharing never replays an accepted command after a transport failure.

Invalidations, explicit Clear(), and tracking continuity changes prevent new callers from joining older work. Callers already waiting may receive their original read result, but the existing invalidation fences reject stale cache insertion. Retirement is conservative: an invalidation currently ends joining for all pending identities, even those with unrelated keys. Reads started during an invalidation run independently and cannot become a source for later callers. Overlapping invalidations keep that interval open until every cache-state change finishes. Completed work is always removed, including oversized responses and other replies that cannot enter the cache. Cache hit/miss counters remain per caller, not per wire request. The process-wide observable counter respire.client_cache.shared_read.retirements counts pending identities removed by invalidation, clearing, or continuity loss. Normal completion and last-caller cancellation are excluded. Use this counter to assess how often churn prevents new callers from joining existing work.

Sharing adds bookkeeping and owned-result copies on misses. Keep the default independent requests for workloads with little contention. A sole remaining waiter can take the producer's owned result; other waiters receive separate copies. Cache hits retain their existing fast path. The CI contention benchmark compares default single-caller misses, opted-in single-caller misses, and opted-in 32-caller bursts for GET, MGET, and HGET, plus hot GET, against both same-run baseline controls. Latency and allocations include one complete burst and its local cache eviction. Process CPU counters include benchmark warmup/calibration and background client work; they are diagnostic, not per-operation CPU samples.

Partial hash reads​

Set ClientSideCache = new() { ReuseHashFields = true } to enable partial hash reads. The default is false and retains exact-query HMGET caching. When enabled, immediate Hashes.GetManyAsync and raw HMGET calls look up each field using the same cache identity as HGET. Cached fields are returned locally; all missing fields are sent in one HMGET. Results retain requested order, duplicate fields, and nulls for absent hashes or fields. An all-hit request sends no command. Binary fields are supported through raw command arguments, which are snapshotted before asynchronous work. Typed facets resolve WithKeyPrefix; raw commands continue to require physical keys explicitly.

The cache stores each field with a dependency on its hash key. Redis invalidations and local hash writes evict every cached field of that hash. In-flight invalidation, clear, and reconnect reject stale insertion. A malformed array or invalid field response rejects the whole reply before any field is cached. Cluster MOVED recovery re-establishes tracked reads; ASK replies are returned without caching the untracked migration target.

With both ReuseHashFields and CoalesceConcurrentMisses enabled, concurrent requests with the same physical hash key and identical ordered missing fields share one HMGET producer. Full field lists may differ when their cached fields differ. Each caller keeps its own cached values, output order, and independently owned result. Different missing lists run independently; they are not split into per-field requests. For example, missing lists [a, b] and [b, a] do not share one producer: matching uses argument order, not set equality. Cancellation, invalidation, and continuity changes follow the shared-read rules above. Hashes outside KeyPrefixes bypass per-field reuse.

Hit/miss statistics count field lookups, including repeated fields. As with cached MGET, a result may combine values cached at different times; use an uncached transaction when an atomic server snapshot is required. Batched/transactional commands retain their existing execution behavior. Disabling client-side caching leaves the ordinary HMGET wire path unchanged.

Typed Strings.GetManyAsync retains its existing per-key partial-hit path and checks all Cluster slots even on all-hit requests. Raw MGET uses exact-query caching by default and the typed per-key path when coalescing is enabled; it does not split overlapping lists into individual GET requests.

Field reuse trades additional entries, lookup work, and owned-result allocations for fewer transferred values on overlapping field lists. Small local Redis responses can be slower in this mode, even with partial hits; it is not a universal optimization. Benchmark your payload sizes, field overlap, and network conditions before enabling it.

The CI hash benchmark compares default and opted-in 0/4, 2/4, and 4/4 cached fields, with the default path also checked against two same-run baseline controls. Each measured batch reads 512 independent hashes once. Cache clearing and field priming happen outside the measured batch, so misses cannot turn into hits partway through an iteration. Process CPU diagnostics include that priming and benchmark warmup/calibration.

Populate missing values with a factory​

GetOrSetAsync<T>(key, factory, ttl, cancellationToken) combines a tracked read with a conditional cache-aside write. Enable ClientSideCache; Redis 7 or later is required for SET NX GET. Existing hits use normal local caching, serialization and key-prefix rules.

Support for NX and GET together starts in Redis 7.0; GET alone was introduced in 6.2. The helper does not preflight server capabilities. On an unsupported server or proxy, a miss can run the factory before the conditional write fails with the original server error. Factory work and accepted writes are not replayed. Use this helper only on deployments supporting that command combination, and keep factories safe to run without a subsequent write. The default IRespireClient implementation throws NotSupportedException; third-party implementations and decorators must implement or forward the helper explicitly.

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 once for that producer. SET NX GET atomically stores the computed value only if the key is still absent, or returns the current writer's value. It preserves an existing winner's TTL. It does not fence an intervening create/delete cycle: if the key is absent again when SET runs, the computed value can be installed. Results describe the read or atomic SET, and another writer can change the key immediately afterward. There is no distributed lock or exactly-once factory guarantee.

The TTL must be at least one millisecond, is truncated to whole milliseconds, and begins at the successful server write. Hits and failed conditional writes do not extend expiry. Local cache lifetime is configured separately; server expiration is observed through normal tracking invalidation, subject to notification delivery and connection-failure detection.

A factory returning null does not write or memoize its result. Later calls can run it again. An existing stored 0, empty string, or serialized JSON null is present and does not run the factory. Mutable typed results are deserialized independently for each caller. The factory's object is never returned directly. Use the same serialization contract for every writer of a key.

CoalesceConcurrentMisses also controls factory sharing. With it enabled, calls on the same client with the same physical key, generic type and millisecond TTL share the first factory. Different callbacks with that identity must be interchangeable. Prefix views share their root's coordinator; different clients do not. Invalidation retires joining, so a later caller can start another factory while an earlier one is still running. With coalescing disabled, each missed call can run its own factory. A factory must not recursively await the same shared identity.

Each cancellation token bounds that caller's wait. With sharing enabled, one cancellation does not stop remaining callers; the last caller leaving or client disposal cancels the factory token cooperatively. Exceptions from shared-token cancellation callbacks can fault remaining waiters but do not interrupt client cleanup. Without sharing, the factory receives the caller's token. Factories must observe cancellation to stop promptly. A late result after cancellation or client disposal is not sent as a new write, but a write already accepted by Redis may still execute. CommandTimeout bounds Redis operations, not application factory work; supply a caller timeout when needed. Factory, serialization and Redis errors propagate without automatically retrying a factory or replaying an accepted write.

Writes use the ordinary invalidation fences and invalidate other tracking clients. The SET result is deliberately not inserted directly into the local cache: it is not a tracked read. The next GET registers tracking and populates the cache. This also avoids caching a losing factory's value or a response made stale by a subsequent write.

The cache-aside benchmark compares a hot hit and complete miss bursts against manual GET/SET NX GET composition on the same baseline/candidate/baseline runner. It measures default single callers, opted-in single callers and 32 callers with a simulated asynchronous loader. Reports retain allocations, factory invocation counts, latency and diagnostic process CPU; loader latency means these results are not raw Redis throughput measurements.

Why this is different​

StackExchange.Redis 3.1.13 supports RESP3 and exposes keyspace notifications, but it does not provide an equivalent built-in server-assisted local response cache. Its keyspace-notification documentation presents notifications as a building block for an application-defined invalidation strategy. That approach requires Redis server configuration plus application-owned storage, subscription, node coverage, bounds, command eligibility, and invalidation-race handling.

Respire uses Redis CLIENT TRACKING directly. No notification channel or global notify-keyspace-events setting is required. Redis's own client-side caching support table does not currently list StackExchange.Redis and warns that exposing CLIENT TRACKING alone is not the same as implementing a client cache.

Measured impact​

These results compare a local Respire cache hit with an ordinary StackExchange.Redis server read. They demonstrate the value of avoiding a network round trip—not a claim that Respire's uncached wire path is hundreds of times faster. 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

With OPTIN, Redis tracks only misses Respire deliberately sends with CLIENT CACHING YES. Local mutations also evict before and after execution. If tracking continuity is lost, Respire clears affected cache state instead of trusting entries whose invalidations may have been missed.

Broadcast tracking​

OptIn remains the default. Choose Broadcast when invalidations for a known keyspace are preferable to Redis registering each read key:

await using var redis = await RespireClient.ConnectAsync(new RespireOptions
{
Endpoints = { new RespireEndpoint("localhost") },
ClientSideCache = new()
{
TrackingMode = RespireClientTrackingMode.Broadcast,
KeyPrefixes = ["tenant:products:", "tenant:prices:"],
},
});

Each cache-bearing connection sends CLIENT TRACKING ON BCAST PREFIX ... with the configured KeyPrefixes during setup and repeats it on reconnect; empty KeyPrefixes sends plain BCAST, covering every key. Broadcast reads omit CLIENT CACHING YES. Coverage rules are the same as in Choose what gets cached. Local writes, in-flight invalidations, and reconnect continuity use the same eviction and stale-insertion checks as OptIn.

RESP3 remains required. Standalone, discovered Sentinel data connections, and Redis/Valkey Cluster data nodes retain the selected configuration; Cluster slot and database restrictions still apply. Blocking/dedicated commands, batches, and transactions continue to bypass caching. Changing mode or prefixes requires creating a new client. Non-Cluster Broadcast clients can use one in-flight command slot. OptIn needs two for its validated command prefix, and Cluster caching needs two in either mode for atomic ASKING plus command redirects.

Redis documents that BCAST trades per-read tracking entries for invalidations on all matching writes, even when this client never read those keys. More prefixes add server work, and broad prefixes can increase network and eviction traffic. See CLIENT TRACKING and the broadcast reference. The cache-tracking CI benchmark compares local hits and an external write/invalidation/read cycle for OPTIN, BCAST-all, and BCAST with one or 256 prefixes, plus OPTIN against two pinned baseline controls. The invalidation cycle includes cooperative polling until the application observes eviction; its CPU totals include that work. Process totals also include warmup and background work; no universal performance win is implied.

Observe invalidations​

Use SubscribeInvalidations to wake a local coordinator when a physical key may need to be read again. Subscribe before the first read so an invalidation cannot fall between that 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 owned RespireClientCacheInvalidation.Key identifies the physical wire key. Binary keys, including empty keys, retain byte identity after registration even if the caller changes the original buffer. Prefix views share the same cache: a view with WithKeyPrefix("tenant:") must subscribe to tenant:jobs:ready, not jobs:ready.

Registration does not issue a command, warm an entry, or register server tracking. OPTIN requires an eligible cached read; read again after each wake-up to rearm tracking. BCAST requires a key inside the configured physical prefixes; uncovered subscriptions throw ArgumentException. The subscription remains registered across reconnects, but cannot recover events lost during a disconnect. No server notification is promised for untracked keys.

Reasons combines ServerInvalidation, LocalMutation, ExplicitClear, and ContinuityLost flags. Local mutations can invalidate before dispatch and after completion, including when the value did not change. Redis-wide invalidations, Clear(), unknown mutations, and continuity loss wake every subscription. TTL expiry and capacity eviction do not produce notifications. LocalMutation also covers conservative whole-cache flushes for unknown commands; it does not identify which observed key, if any, changed on the server. Cache eviction and stale-read rejection happen before notifications are scheduled.

Each subscription serializes callbacks on the ThreadPool without flowing the registration's ExecutionContext. Callbacks never run inline on the socket parser or invalidating thread. A slow observer has at most one pending wake-up: its reason flags are combined, preserving continuity loss. Intermediate events, event counts, and cross-subscription ordering are not preserved. Keep callbacks short, as in the bounded channel example. A throwing callback cannot interrupt eviction or other observers; LastObserverException retains its latest exception and later callbacks continue. Use synchronous callbacks; async void exceptions cannot be captured by this API.

Dispatch cost scales with the number of subscriptions: a global flush can schedule one worker per subscription. Registration and disposal copy the observer dictionary and the affected key's subscriber array, then publish an immutable snapshot. Per-key invalidation and global flushes read that snapshot without taking the registration gate or allocating a lookup snapshot. Registering or removing an observer costs O(observed keys + subscribers for that key); use long-lived subscriptions rather than registering on every request. A global flush still does work proportional to the subscriber count on its caller, which can hold other cache gates. Client disposal detaches the entire registry once and stops subscriptions without copying it or reacquiring the registration gate for each subscription. There is no additional global subscription limit. Bound the number of live subscriptions in your application; the one-pending limit applies separately to each subscription. Only the most recent callback exception is retained. Catch and log inside your callback if every failure must be recorded. Third-party implementations of IRespireClientSideCache remain source-compatible; the default observation method throws NotSupportedException unless implemented. Implementations that support observation can return their own public IRespireClientCacheInvalidationSubscription.

Dispose the returned subscription or cancel its token to discard pending delivery. Client disposal also stops every subscription. Disposal does not wait for a callback already selected for execution, so that callback may finish afterward and may safely dispose itself or its client.

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

Bounds​

Tune entry count, approximate owned bytes, and local lifetime together:

ClientSideCache = new RespireClientSideCacheOptions
{
MaxEntries = 25_000,
MaxSizeBytes = 128L * 1024 * 1024,
LocalExpiration = TimeSpan.FromMinutes(2),
},

An oversized response is returned without being cached. GetLeaseAsync participates without sharing lease ownership. GEOSEARCH with COUNT ... ANY is also excluded because Redis may return an arbitrary early subset. Only exact MEMORY USAGE ... SAMPLES 0 calls are cached; sampled size estimates bypass the cache. Nondeterministic, random, probabilistic, blocking, script/function, time-series, Search, and unkeyed commands bypass caching; so do batches and transactions. Unknown mutations conservatively flush local entries before dispatch and after awaited completion. Respire rejects raw commands that would change protocol, database, or tracking state while this feature is enabled.

ASP.NET Core registration​

Respire.DependencyInjection provides a helper on its mutable options builder:

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

Diagnostics​

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

Console.WriteLine($"{statistics.Hits} hits; {statistics.SizeBytes} bytes");
cache.Clear();

The Respire OpenTelemetry meter emits hit, miss, invalidation, eviction, and continuity-flush counters.

Consistency boundary​

Respire rejects a stale read response when an invalidation races cache insertion. It also flushes after awaited local mutations and on detected connection loss, reconnect, redirect, and cluster topology retirement. ASK retries return their value without caching because Redis applies both ASKING and CLIENT CACHING YES to the next command. Like every server-assisted client cache, it cannot observe invalidations across an undetected network partition. Configure TCP keepalive or ConnectionIdleReadTimeout, and keep a finite local TTL, when bounded failure detection matters.