HybridCache is a caching API introduced by Microsoft in .NET 9 that unifies in-process (L1) and distributed (L2) caching behind a single abstraction, along with a default implementation.
NCache.OSS.Caching.Hybrid is an implementation of the HybridCache abstraction that uses NCache as the distributed (L2) cache layer. It is intended as a direct replacement for the default implementation in applications that already use, or want to use, an NCache cluster.
In addition to satisfying the standard HybridCache contract, this implementation uses NCache's built-in Pub/Sub messaging to keep the in-process (L1) cache synchronized across every application instance connected to the cluster. Under the default HybridCache implementation, L1 caches on different nodes are independent and can diverge until entries expire; this implementation propagates updates, removals, and tag invalidations to every node in real time.
| Abstraction | Microsoft.Extensions.Caching.Hybrid.HybridCache |
| L1 layer | NCache In-Proc cache |
| L2 layer | NCache distributed cluster |
| Cross-node sync | Automatic, via NCache Pub/Sub |
| Minimum .NET | .NET 8 |
The default Microsoft implementation of HybridCache gives you:
- an L1 (in-process, memory) cache
- an L2 (distributed) cache, via
IDistributedCache - cache stampede protection, scoped to a single node
- tag-based invalidation
This package gives you all of that, plus:
- real-time L1 ⇄ L2 synchronization across every node — see below
- cross-node
REMOVEand wildcard (*) invalidation - bulk key / tag operations, batched into a single round-trip and a single sync message
- fine-grained
HybridCacheEntryFlagsfor per-call control over which layer is read/written - structured logging via
ILogger, with a dedicated diagnostic category
This is the main thing this package adds on top of HybridCache: every node's L1 cache stays in sync, in real time, without you doing anything extra.
NCache.OSS.Caching.Hybrid provides its own backplane for L1 synchronization, built on top of NCache's Pub/Sub API — that backplane keeps every node's L1 cache in sync automatically, with no separate broker to stand up or wire in yourself.
flowchart LR
subgraph App["🖥️ Application Tier"]
direction TB
N1["🧠 Node 1 — L1"]
N2["🧠 Node 2 — L1"]
N3["🧠 Node 3 — L1"]
end
PS(("📡 Pub/Sub<br/>UPDATE · REMOVE · TAG"))
subgraph Cluster["🗄️ NCache Cluster (L2)"]
direction TB
S1[("Server 1")]
S2[("Server 2")]
end
N1 <--> PS
N2 <--> PS
N3 <--> PS
PS <--> Cluster
classDef app fill:#eef2f7,stroke:#4a5568,color:#1a202c,stroke-width:1.5px
classDef bus fill:#fff8e6,stroke:#b7791f,color:#5c3d00,stroke-width:1.5px
classDef cluster fill:#f0f5f0,stroke:#4a5568,color:#1a202c,stroke-width:1.5px
class N1,N2,N3 app
class PS bus
class S1,S2 cluster
Concretely, here's what triggers a sync message and what every other node does with it:
| Operation | What's published | What other nodes do |
|---|---|---|
SetAsync |
UPDATE for the key |
Refresh or invalidate their local L1 entry for that key |
RemoveAsync (single or bulk) |
REMOVE for the key(s) |
Evict the key(s) from their local L1 |
RemoveByTagAsync |
TAG invalidation, with a timestamp |
Treat any L1/L2 entry created before that timestamp as stale |
RemoveByTagAsync("*") |
WILDCARD invalidation |
Treat every entry as stale, cluster-wide |
Tag invalidations don't physically delete anything — a timestamp is persisted in L2 (as a sentinel key) and broadcast to every node. On the next read, any entry created before that timestamp is treated as invalid and re-fetched, regardless of which node originally cached it. This means tag invalidation is both instant across the cluster and durable, since the sentinel lives in L2, not just in memory on one node.
The net effect: a write, remove, or invalidation on any one node is reflected on every other node within the cluster in real time — you don't write any additional code for this, and there's no separate component to configure. It falls out of registering the package against your NCache cluster.
A few other things this implementation adds beyond the base HybridCache contract:
- Bulk operations — passing multiple keys or tags to
RemoveAsync/RemoveByTagAsyncbatches them into a single L1/L2 operation and a single Pub/Sub message, instead of one round-trip per item. - Fine-grained cache flags —
HybridCacheEntryFlagslets you disable L1 or L2 reads/writes independently, per call, so hot-but-volatile data can skip L1 while long-lived data can skip L2. - Independent expirations —
Expiration(L2) andLocalCacheExpiration(L1) are set separately, so your distributed copy can safely outlive your local copy (or vice versa). - Structured, diagnosable logging — every layer logs through
ILogger, with a dedicated category for debug-level tracing of cache/sync behavior when you need to troubleshoot.
|
|
| Package | Version |
|---|---|
NCache.OSS.Caching.Hybrid |
5.3.6.1 |
Alachisoft.NCache.Opensource.SDK |
>= 5.3.6.2 |
Microsoft.Extensions.Caching.Hybrid |
>= 10.4.0 |
.NET CLI
dotnet add package NCache.OSS.Caching.HybridNuGet Package Manager Console
Install-Package NCache.OSS.Caching.Hybrid| # | Requirement |
|---|---|
| 1 | 🖧 A running NCache Server cluster |
| 2 | 💾 An In-Proc cache configured for L1 |
| 3 | 🗄️ A Replicated cache configured for L2 |
{
"NCacheHybridCacheConfiguration": {
"LocalCacheName": "myLocalCache",
"DistributedCacheName": "myDistributedCache",
"ServerList": [
{ "Ip": "192.168.1.100", "Port": 9800 },
{ "Ip": "192.168.1.101", "Port": 9800 }
],
"EnableLogs": true
}
}Using IConfiguration
var builder = WebApplication.CreateBuilder(args);
// Option 1: Bind from configuration section
var config = builder.Configuration.GetSection("NCacheHybridCacheConfiguration");
builder.Services.AddNCacheHybridCache(config);
// Option 2: Bind directly (auto-detects section name)
builder.Services.AddNCacheHybridCache(builder.Configuration);Using an action delegate
builder.Services.AddNCacheHybridCache(options =>
{
options.LocalCacheName = "myLocalCache";
options.DistributedCacheName = "myDistributedCache";
options.ServerList = new List<ServerConfig>
{
new ServerConfig { Ip = "192.168.1.100", Port = 9800 }
};
options.EnableLogs = true;
});public class ProductService
{
private readonly HybridCache _cache;
public ProductService(HybridCache cache) => _cache = cache;
public async Task<Product> GetProductAsync(int productId)
{
return await _cache.GetOrCreateAsync(
key: $"product:{productId}",
state: productId,
factory: async (id, ct) => await _database.GetProductAsync(id, ct),
tags: new[] { "products", $"category:{product.CategoryId}" }
);
}
}ValueTask<T> GetOrCreateAsync<TState, T>(
string key,
TState state,
Func<TState, CancellationToken, ValueTask<T>> factory,
HybridCacheEntryOptions? options = null,
IEnumerable<string>? tags = null,
CancellationToken cancellationToken = default
);| Parameter | Description |
|---|---|
key |
Unique cache key (null/empty handled gracefully) |
state |
State passed to the factory |
factory |
Async function invoked on cache miss |
options |
Expiration, flags |
tags |
Tags for grouping/invalidation |
cancellationToken |
Passed to factory and semaphore |
Checks L1, then L2, and falls back to the factory on a full miss — with built-in stampede protection so concurrent callers for the same key don't all hit the factory at once.
var user = await _cache.GetOrCreateAsync(
key: $"user:{userId}",
state: userId,
factory: async (id, ct) => await _userRepository.GetByIdAsync(id, ct),
options: new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(30), // L2 expiration
LocalCacheExpiration = TimeSpan.FromMinutes(5) // L1 expiration (shorter)
},
tags: new[] { "users", $"tenant:{tenantId}" },
cancellationToken: cancellationToken
);ValueTask SetAsync<T>(
string key,
T value,
HybridCacheEntryOptions? options = null,
IEnumerable<string>? tags = null,
CancellationToken cancellationToken = default
);Writes to L2, publishes a Pub/Sub UPDATE so every other node syncs its L1, then writes L1 locally.
await _cache.SetAsync(
key: $"config:{configKey}",
value: configValue,
options: new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromHours(1),
LocalCacheExpiration = TimeSpan.FromMinutes(10)
},
tags: new[] { "configuration" }
);Single key
ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default);Removes the key from L1 and L2, then publishes a REMOVE message so other nodes sync.
await _cache.RemoveAsync($"product:{productId}");Bulk keys
ValueTask RemoveAsync(IEnumerable<string> keys, CancellationToken cancellationToken = default);Removes multiple keys in one bulk L1/L2 operation, with a single Pub/Sub notification for the batch.
var keysToRemove = new[] { "product:1", "product:2", "product:3" };
await _cache.RemoveAsync(keysToRemove);ValueTask RemoveByTagAsync(string tag, CancellationToken cancellationToken = default);
ValueTask RemoveByTagAsync(IEnumerable<string> tags, CancellationToken cancellationToken = default);This does not physically delete entries — it records an invalidation timestamp per tag (persisted in L2 as a sentinel key and broadcast via Pub/Sub), so future lookups across every node treat matching entries as stale. Using "*" as the tag performs a global, cluster-wide invalidation.
// Invalidate all products in a category
await _cache.RemoveByTagAsync($"category:{categoryId}");
// Bulk — single Pub/Sub notification for all tags
await _cache.RemoveByTagAsync(new[] { "products", "users", "orders" });
// Wildcard — invalidate everything
await _cache.RemoveByTagAsync("*");💡 Bulk tag invalidation updates multiple tag timestamps in a single L2 operation and publishes a single Pub/Sub notification — always prefer it over looping single-tag calls.
NCacheHybridCacheConfiguration
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
LocalCacheName |
string |
✅ | – | Name of the NCache In-Proc cache (L1) |
DistributedCacheName |
string |
✅ | – | Name of the NCache distributed cache (L2) |
ServerList |
IList<ServerConfig> |
✅ | – | NCache L2 server nodes |
EnableLogs |
bool |
❌ | false |
Enable detailed logging |
ServerConfig
| Property | Type | Default | Description |
|---|---|---|---|
Ip |
string |
– | NCache server IP |
Port |
int |
9800 |
NCache server port |
HybridCacheEntryOptions
| Property | Type | Description |
|---|---|---|
Expiration |
TimeSpan? |
L2 entry expiration; falls back to DefaultEntryOptions.Expiration |
LocalCacheExpiration |
TimeSpan? |
L1 entry expiration; falls back to LocalCacheExpiration config |
Flags |
HybridCacheEntryFlags |
Fine-grained cache behavior |
HybridCacheEntryFlags
| Flag | Description |
|---|---|
None |
Default — read/write both layers |
DisableLocalCacheRead |
Skip L1 reads |
DisableLocalCacheWrite |
Skip L1 writes |
DisableLocalCache |
Skip L1 entirely |
DisableDistributedCacheRead |
Skip L2 reads |
DisableDistributedCacheWrite |
Skip L2 writes |
DisableDistributedCache |
Skip L2 entirely |
DisableUnderlyingData |
Return default(T) instead of invoking factory for null/empty keys |
|
⏱️ Expiration tiers var options = new HybridCacheEntryOptions
{
// L2 = source of truth, longer
Expiration =
TimeSpan.FromHours(1),
// L1 = quick refresh, shorter
LocalCacheExpiration =
TimeSpan.FromMinutes(5)
}; |
🏷️ Hierarchical tagging var tags = new[]
{
"entity:product",
$"category:{categoryId}",
$"tenant:{tenantId}",
$"product:{productId}"
}; |
🎯 Selective layers // Hot, rarely-changing data
var o1 = new HybridCacheEntryOptions
{
LocalCacheExpiration =
TimeSpan.FromMinutes(30)
};
// Frequently-changing, skip L1
var o2 = new HybridCacheEntryOptions
{
Flags = HybridCacheEntryFlags
.DisableLocalCache
}; |
Bulk over loop — always:
// ✅ One L1/L2 bulk op + one Pub/Sub message
await _cache.RemoveAsync(products.Select(p => $"product:{p.Id}"));
// ❌ N round-trips + N Pub/Sub messages
foreach (var product in products)
await _cache.RemoveAsync($"product:{product.Id}");| Issue | Cause | Solution |
|---|---|---|
💥 InvalidOperationException on startup |
Invalid configuration | Verify LocalCacheName, DistributedCacheName, ServerList |
| ⏳ Connection timeout | Server unreachable | Check NCache server status & firewall rules |
| 🔀 Data inconsistency across nodes | Pub/Sub not working | Verify the messaging topic exists and is reachable |
| ❓ Unexpected null-key behavior | DisableUnderlyingData flag |
If set → default(T); otherwise factory is invoked |
| 🕸️ Stale data after tag invalidation | Sentinel missing | Confirm "$$sentinel$$:{tag}" exists in L2 |
Enable diagnostic logging:
{
"Logging": {
"LogLevel": {
"NCache.Microsoft.Extensions.Caching.Hybrid.Opensource": "Debug"
}
},
"NCacheHybridCacheConfiguration": {
"EnableLogs": true
}
}Internal optimizations worth knowing about
isItemInvalidflag: an L1 hit with invalid tags skips L2 entirely and goes straight to the factory.TRY/FINALLYcleanup: semaphore locks always release, even on exceptions.- Expiration precedence:
options.Expiration> config default (L2);options.LocalCacheExpiration> config default (L1). - Bulk operations:
RemoveBulk/GetBulkused automatically for multi-key/tag calls. - Sentinel keys: tag timestamps persist in L2 as
"$$sentinel$$:{tag}"/"$$sentinel$$:*".
Copyright © 2005–2026 Alachisoft. All rights reserved.