Coming from StackExchange.Redis
StackExchange.Redis is the established .NET Redis client. Respire covers the same core ground — multiplexed connections, automatic pipelining, Cluster, Sentinel, pub/sub, transactions, and scripting — with a different API shape and some capabilities StackExchange.Redis does not provide. This page compares the two and maps common StackExchange.Redis code to Respire.
Comparisons describe StackExchange.Redis 3.3.1, the version used by Respire's comparison benchmarks. Recheck the StackExchange.Redis claims on this page when that version changes. The upstream configuration and failover/retry documentation describe its RESP3 defaults, smart client handoffs, connection groups, and retry categories.
Respire's public API may still change before a stable release. See the roadmap.
Why switch
Respire gives you:
- server-assisted client-side caching for hot reads;
- blocking commands such as
BLPOP,BLMOVE, andXREADGROUP BLOCKwithout stalling other traffic; - typed results (
string?,long,T?) instead of a protocol value union; IAsyncEnumerablepub/sub, stream reads, and scans;- an in-memory test server and built-in OpenTelemetry.
Uncached wire performance is comparable. See the benchmarks and stress tests for current measurements against StackExchange.Redis.
Feature comparison
| Capability | StackExchange.Redis | Respire |
|---|---|---|
| Target frameworks | .NET Framework 4.6.1+, netstandard2.0, .NET 8+ | .NET 8 and .NET 10 |
| API style | Synchronous and asynchronous methods | Asynchronous only (ValueTask) |
| Command surface | Flat methods on IDatabase (HashGet, ListLeftPush) | Facets per data type (redis.Hashes.GetStringAsync) |
| Values | RedisValue union | Typed results; RespireValue for arguments |
| Automatic pipelining | Yes | Yes |
| Protocol | RESP3 attempted by default when HELLO is enabled and the configured server version is 6+; RESP2 fallback and explicit override | RESP3 preferred by default, with RESP2 fallback |
| Server-assisted client-side cache | No | Yes, opt-in |
Blocking commands (BLPOP, BLMOVE, ...) | No typed APIs; they would stall the shared connection | Typed APIs on dedicated pooled connections |
| Pub/sub | Handlers or ChannelMessageQueue | IAsyncEnumerable subscriptions with delivery-gap markers |
| Sharded pub/sub (Redis 7) | Yes | Yes |
| Transactions | AddCondition checks | WATCH-based optimistic concurrency |
| Distributed locks | LockTake, LockExtend, LockRelease | Raw-token APIs plus managed lock handles with keep-alive |
| Read from replicas | CommandFlags.PreferReplica / DemandReplica per command | ReadFrom option or WithReadFrom view, with hedged and zone-aware reads |
| Fire-and-forget | CommandFlags.FireAndForget on any command | ExecuteFireAndForgetAsync for raw commands |
| Multiple databases | GetDatabase(n) per call | One database per client (Database option) |
| Health-checked failover between deployments | ConnectGroupAsync connection groups | RespireFailoverGroup; read ActiveClient for each operation |
| Smart client handoffs | Opt-in maintenance notifications and endpoint handoff; maintenance notifications are disabled for connection-group members in 3.3.1 | Opt-in maintenance notifications and handoff; combining handoff with failover groups is tracked in #893 |
| Command retry policy | Async WithRetry(...) wrapper with RetryPolicy and CommandRetry* categories | Connection recovery and specific routing retries; a general per-command retry policy is tracked in #862 |
| Diagnostics | RegisterProfiler profiling sessions | OpenTelemetry ActivitySource and Meter |
IDistributedCache | Microsoft.Extensions.Caching.StackExchangeRedis | Respire.Caching; entries are interchangeable while ValueCodec is unset |
| Module commands | Raw Execute | Typed Json, Search, TimeSeries, and Probabilistic packages |
| Test double | None built in | Respire.Testing in-memory server |
Behavior differences
These differences can change application behavior after a migration. Review them before you switch.
- No synchronous API. Every command returns
ValueTaskorValueTask<T>. Replace sync calls withawait; do not block on.Result. - Missing values are
nullordefault.GetStringAsyncreturnsstring?andGetAsync<T>returnsT?. There is noRedisValue.IsNullcheck. For a value type,GetAsync<int>returns0for a missing key. UseTryGetAsync<T>and checkFoundwhen a missing key must differ from a stored default. - Server errors throw. A Redis error reply throws
RespireServerException; itsCodecarries the Redis error class. - Timeouts are longer by default.
CommandTimeoutdefaults to 10 seconds; StackExchange.Redis defaults to 5 seconds. A timed-out command may still run on the server, as with StackExchange.Redis.asyncTimeoutandsyncTimeoutin a connection string setCommandTimeout. - Cancellation is supported. Most methods accept a
CancellationToken. Cancellation abandons the wait only; a command that was already written still runs. - Batch results throw before the batch runs. Reading
RespirePending<T>.ResultbeforeExecuteAsyncthrows instead of deadlocking. - RESP3 changes raw reply shapes. Typed methods hide the difference. Raw
RespireResultcallers can receive maps, sets, and doubles. SetProtocol = RespProtocol.Resp2if raw callers need RESP2 shapes. Client-side caching always uses RESP3, so use a separate client withoutClientSideCachefor those callers. See protocol negotiation. - One database per client. Create one client per database index, or use key prefixes.
SELECTcannot switch a shared connection. - Multiple endpoints need a mode. A comma-delimited string with several endpoints must set
cluster=trueorserviceName=.... Respire rejects an ambiguous list instead of guessing. - Failover groups do not move existing calls.
RespireFailoverGroupselects a deployment for new operations only. Readgroup.ActiveClientfor each operation instead of keeping a client reference. In-flight calls on the old deployment are not replayed. - Key scans cover the whole deployment.
server.KeysAsyncscans one server.Keys.ScanAsyncscans the client's configured database, and every primary in Cluster mode. - Subscriptions report delivery gaps. A subscription stream yields
RespireMessageKind.Gapitems after a reconnect or buffer overflow. Checkmessage.Kindbefore reading the payload.
Connecting
Respire accepts most StackExchange.Redis connection strings, so existing configuration can stay in place:
await using var redis = await RespireClient.ConnectAsync(
"cache-a:6380,password=secret,ssl=true,defaultDatabase=2");
Respire rejects unknown options with ArgumentException instead of ignoring them. Remove
StackExchange.Redis-only options such as abortConnect before you pass the string to Respire. See
StackExchange.Redis connection strings
for the supported options. URIs such as rediss://user:pass@cache-a:6380/2 and RespireOptions
also work.
| StackExchange.Redis | Respire |
|---|---|
ConnectionMultiplexer.ConnectAsync(config) | RespireClient.ConnectAsync(config) |
ConnectionMultiplexer.Connect(config) with abortConnect=false | RespireClient.Create(config) without abortConnect (connects lazily) |
multiplexer.GetDatabase() | The client itself (IRespireClient) |
multiplexer.GetDatabase(2) | A separate client with Database = 2 |
multiplexer.GetSubscriber() | redis.SubscribeAsync and redis.PublishAsync on the client |
multiplexer.GetServer(endpoint) | No per-endpoint selection. redis.Server uses normal routing; *OnAllNodesAsync methods run on every node |
ConnectionFailed, ConnectionRestored | ConnectionStateChanged |
multiplexer.IsConnected | redis.IsConnected |
services.AddSingleton<IConnectionMultiplexer>(...) | services.AddRespire(...) |
Types
| StackExchange.Redis | Respire |
|---|---|
RedisKey | RespireKey (implicit from string, byte[], ReadOnlyMemory<byte>) |
RedisValue (arguments) | RespireValue (implicit from strings, bytes, numbers, bool, Guid, and more) |
RedisValue (results) | string?, byte[]?, long, bool, double, or T? |
RedisResult | RespireResult — owns pooled memory, so dispose it |
RedisChannel | RespireChannel |
TimeSpan? expiry | RespireExpiry (implicit from TimeSpan and DateTimeOffset) |
When.NotExists, When.Exists | SetWhen.NotExists, SetWhen.Exists |
KeyTimeToLive result TimeSpan? | RespireTtl, which separates a missing key from a key without expiry |
Commands
Common string and key commands are on the client. Other commands are grouped into facets:
Strings, Keys, Hashes, Lists, Sets, SortedSets, Streams, Bitmaps, HyperLogLog,
Geo, VectorSets, Scripts, Functions, Locks, and Server.
// StackExchange.Redis
IDatabase db = multiplexer.GetDatabase();
await db.StringSetAsync("user:1:name", "Ada", TimeSpan.FromMinutes(5), When.NotExists);
string? name = await db.StringGetAsync("user:1:name");
long visits = await db.StringIncrementAsync("visits");
await db.HashSetAsync("user:1", "email", "ada@example.com");
await db.SortedSetAddAsync("leaderboard", "ada", 42);
TimeSpan? ttl = await db.KeyTimeToLiveAsync("user:1:name");
// Respire
await redis.SetAsync("user:1:name", "Ada", TimeSpan.FromMinutes(5), SetWhen.NotExists);
string? name = await redis.GetStringAsync("user:1:name");
long visits = await redis.IncrementAsync("visits");
await redis.Hashes.SetAsync("user:1", "email", "ada@example.com");
await redis.SortedSets.AddAsync("leaderboard", "ada", 42);
RespireTtl ttl = await redis.Keys.ExpiryAsync("user:1:name");
| StackExchange.Redis | Respire |
|---|---|
StringGetAsync, StringSetAsync | GetStringAsync, GetAsync<T>, SetAsync on the client |
StringIncrementAsync, StringDecrementAsync | IncrementAsync, DecrementAsync |
KeyDeleteAsync, KeyExistsAsync, KeyExpireAsync | DeleteAsync, ExistsAsync, ExpireAsync |
HashGetAsync, HashSetAsync, HashGetAllAsync | Hashes.GetStringAsync or Hashes.GetBytesAsync, Hashes.SetAsync, Hashes.GetAllAsync or Hashes.GetAllAsync<byte[]> |
ListLeftPushAsync, ListRightPopAsync | Lists.LeftPushAsync, Lists.RightPopAsync |
SetAddAsync, SetMembersAsync | Sets.AddAsync, Sets.MembersAsync |
SortedSetAddAsync, SortedSetRangeByScoreAsync | SortedSets.AddAsync, SortedSets.RangeByScoreAsync |
StreamAddAsync, StreamReadGroupAsync | Streams.AddAsync; Streams.ReadGroupOnceAsync for one batch; single-key Streams.ReadGroupAsync reads continuously |
server.KeysAsync(pattern) | Keys.ScanAsync(pattern) (IAsyncEnumerable; scans every primary in Cluster mode) |
StringGetLeaseAsync | Strings.GetLeaseAsync |
ExecuteAsync("CMD", args) | ExecuteAsync("CMD", args) or the generated RespireCommands catalog |
CommandFlags.FireAndForget | ExecuteFireAndForgetAsync |
RedisValue results can hold arbitrary bytes. Respire's non-generic read methods return strings,
which decode UTF-8 and replace invalid byte sequences. This applies to strings, hashes, lists,
sets, sorted sets, and Keys.ScanAsync. For binary data, use GetBytesAsync or the generic
<byte[]> overloads, such as GetAsync<byte[]>, Hashes.GetAllAsync<byte[]>,
Lists.RangeAsync<byte[]>, Sets.MembersAsync<byte[]>, and SortedSets.RangeAsync<byte[]>.
Hash field names, stream field names, and scanned keys are always strings. Use raw HGETALL,
XADD, stream read, or SCAN commands when field names or keys are binary. In Cluster mode, a
raw SCAN reaches one node only, so it does not enumerate keys on every primary.
StreamReadGroupAsync returns one batch. The single-key Streams.ReadGroupAsync is a continuous consumer: it
keeps issuing blocking XREADGROUP calls on a dedicated connection and ends only when its
cancellation token is canceled. Use Streams.ReadGroupOnceAsync for a single batch, or the
multi-stream Streams.ReadGroupAsync overload for one page across streams. StreamReadOptions
supports cumulative Redis 8.10 reply limits, NoAck, and Redis 8.4 ClaimMinIdle.
See strings and keys, collections, and raw commands for the full surface.
Batches and transactions
StackExchange.Redis batch and transaction methods return tasks that complete when the batch
runs. Respire returns RespirePending<T>, and the batch methods drop the Async suffix:
using var batch = redis.CreateBatch();
var name = batch.GetString("user:1:name");
var visits = batch.Increment("visits");
await batch.ExecuteAsync();
Console.WriteLine($"{name.Result}: {visits.Result}");
StackExchange.Redis AddCondition checks have no direct equivalent. Use WATCH through
CreateTransactionAsync, read the current state with the client, and retry when CommitAsync
returns false:
bool applied;
do
{
await using var watched = await redis.CreateTransactionAsync(["balance"]);
long current = long.Parse(await redis.GetStringAsync("balance") ?? "0");
watched.Set("balance", current - 100);
applied = await watched.CommitAsync();
}
while (!applied);
If the client can read from replicas, read the input through redis.WithReadFrom(RespireReadFrom.Primary).
WATCH cannot detect a replica value that was already stale before the watch started.
For single-key compare-and-set on Redis 8.4+, use Strings.SetConditionalAsync or
Strings.DeleteConditionalAsync with a RespireValueCondition instead. See
batches and transactions and
compare values before writing or deleting.
Pub/sub
StackExchange.Redis delivers messages to a handler or a ChannelMessageQueue. Respire returns
a subscription that is an async stream. SubscribeAsync returns after the server acknowledges
the subscription:
await using var subscription = await redis.SubscribeAsync("orders", token);
await foreach (var message in subscription.WithCancellation(token))
{
if (message.Kind == RespireMessageKind.Gap)
{
// Messages may have been lost. Reload authoritative state before continuing.
Console.Error.WriteLine($"Delivery gap: {message.Gap}");
continue;
}
Console.WriteLine($"{message.Channel}: {message.Text}");
}
A Gap item means messages may have been lost during a reconnect or buffer overflow; it has no
channel or payload. message.Text decodes the payload as UTF-8. Use message.Payload or
message.As<byte[]>() for binary payloads. Disposing the subscription unsubscribes. See
pub/sub.
Locks, scripts, and key prefixes
| StackExchange.Redis | Respire |
|---|---|
LockTakeAsync(key, token, expiry) | Locks.TryTakeAsync(key, token, expiry) |
LockExtendAsync | Locks.ResetExpiryAsync |
LockReleaseAsync | Locks.ReleaseAsync |
LockQueryAsync | Locks.GetOwnerTokenAsync |
| — | Locks.AcquireAsync(key, expiry, keepAlive: true) for a managed handle that renews itself; keep-alive is opt-in |
LuaScript.Prepare(source) | RespireScript.Create(source); rewrite @name parameters as KEYS[n] and ARGV[n] |
ScriptEvaluateAsync(script, keys, values) | Scripts.ExecuteAsync(script, keys, args) (EVALSHA with EVAL fallback) |
db.WithKeyPrefix("tenant:") | redis.WithKeyPrefix("tenant:") |
Respire sends script source unchanged. It does not support StackExchange.Redis named @parameter
binding. Rewrite each @key reference as KEYS[n] and each @value reference as ARGV[n], then
pass the keys and arguments as arrays in the same order.
See distributed locks and Lua scripting.
Replicas, Cluster, and Sentinel
| StackExchange.Redis | Respire |
|---|---|
CommandFlags.PreferReplica | ReadFrom = RespireReadFrom.ReplicaPreferred, or redis.WithReadFrom(...) |
CommandFlags.DemandReplica | RespireReadFrom.Replica |
| Cluster endpoints in the connection string | cluster=true, or UseCluster = true |
serviceName=mymaster | serviceName=mymaster, or SentinelPrimaryName |
Respire routes only read-only commands to replicas. See read from replicas and Redis Sentinel.
Exceptions
| StackExchange.Redis | Respire |
|---|---|
RedisConnectionException | RespireConnectionException (RespireAuthenticationException for authentication failures) |
RedisTimeoutException | RespireTimeoutException |
RedisServerException | RespireServerException |
RedisException | RespireException |
Observability
Respire has no profiling-session API. It emits OpenTelemetry traces and metrics from an
ActivitySource and a Meter, both named Respire:
tracing.AddSource("Respire");
metrics.AddMeter("Respire");
See observability.