MS Stack Ch 17 — Caching
IMemoryCache, IDistributedCache, HybridCache (.NET 9), Redis, cache-aside, write-through, stampede protection, invalidation, TTL strategies. Freshness, isolation and load control without invented speed guarantees.
Published Updated
Lesson preparation & details
Level: intermediate
By the end, you should be able to
- Choose cache layers from freshness, isolation and capacity requirements
- Use version-correct HybridCache return types, options and tags
- Reason about stale-fill races, local single-flight and outage load
- Implement HTTP validators and distinguish cache-aside from write-through
Bring with you
Sources checked: . A source check does not change the publication or update date.
Editorial review: · What review means
In this article · 33 sections
Cache API and execution contract
The HybridCache examples use the Microsoft.Extensions.Caching.Hybrid 9.x package and the .NET v9.0.0 abstraction source; it is not a built-in .NET 8 API, though package target-framework compatibility is a separate question. IMemoryCache behavior is checked against ASP.NET Core 8 documentation. Redis/HTTP/Azure service guidance was rechecked against dated official snapshots on 9 October 2026. The 9.x package is an explicit dependency; target-framework compatibility is not the same as being included in the runtime. C# blocks are integration fragments with application-specific types/imports, not one concatenated program. Bounded cache-race, age-budget and validator reference models are checked locally; no .NET compilation, Redis/CDN integration or latency benchmark was executed.
Chapter 17 of From Novice to Fluent on the Modern Microsoft Web Stack — a 22-chapter self-study plan.
Why this chapter
Caching can remove repeated origin work when repeated reads tolerate a specified age. For an illustrative workload, a 90% hit rate at 1,000 reads/s leaves 100 origin reads/s if misses are one origin read each and no retries/warmup occur. That arithmetic is not a 10× end-to-end latency benchmark. Mismanaged caches can leak tenant data, preserve stale dashboards and overwhelm an origin during coordinated misses.
Shipping-level caching means: you reach for IMemoryCache for hot per-instance reference data, IDistributedCache (Redis) when the cache must be shared, and HybridCache when you want both tiers plus stampede protection in one call. Expert-level caching means: you can size cache keys to avoid cardinality blowups, you have run a chaos test to prove your app survives a Redis outage, your invalidation strategy is documented and reviewable, your TTLs map to business-defensible staleness budgets, and you correlate hit-rate degradation with origin load and user-facing latency/error signals. An alert is not guaranteed to precede user impact.
You finish this chapter when you can pick the right cache layer for an arbitrary workload, defend the choice in code review, and explain on demand how cache-aside and write-through differ, and why neither creates an atomic cache/database transaction by magic.
Pick the right tier — L1 in-process, L2 distributed, edge CDN — each for its job.
Design keys defensively — Encode every variable that affects the answer; tenant and authorization-dependent identity/projection when the response depends on them.
TTL with intent — Pick a number that maps to a business staleness contract; document it.
Stampede control — Single-flight or HybridCache coalesces compatible misses within its coordination scope; plan separately for multiple instances.
Invalidate deliberately — TTL, explicit, tag-based, version-prefix — pick one per data shape.
Survive failures — Choose bounded bypass, permitted stale data or explicit unavailability; cache outage fallback can overwhelm origin capacity.
Concepts and depth
Why cache: latency reduction and load shedding
Caches do two distinct jobs that tend to get conflated:
- Latency reduction — the cached path is faster than the origin path. A local hit avoids network/serialization work; distributed and origin paths add different costs. Measure actual p50/p95 and payload sizes rather than assigning universal millisecond constants.
- Load shedding — the origin handles fewer calls because the cache absorbs them. The cache is a shock absorber for the downstream. With one database read per miss and no retries/warmup, a 90% hit rate at 1,000 reads/s leaves 100 database reads/s; fan-out or background fills change that arithmetic.
Both matter, but they pull on different design knobs. Latency reduction rewards keeping the cache close (in-process); load shedding rewards keeping the cache shared (Redis). Use both tiers only when their extra memory, serialization and freshness boundaries earn their cost.
Caching is also, deliberately, the lazy answer to scalability — easier to add than to redesign the data model, and that is fine when the data is naturally read-heavy. It becomes wrong when used as a substitute for a real performance fix in code or schema; cache-hiding a slow query is borrowing time at compound interest.
Cache primitives: IMemoryCache, IDistributedCache, HybridCache
.NET ships three caching abstractions that compose into a complete strategy:
IMemoryCache— in-process. No network hop; per-instance; lost on restart. No serialisation. Best for hot, small, per-instance reference data.IDistributedCache— shared. Backed by Redis (typically), SQL Server, or Azure Cosmos. Pays network and serialization cost; latency is workload/topology-dependent. Shared across instances. Best for shared expensive query results and pseudo-session state.HybridCache(9.x package contract here) — combinesIMemoryCache(L1) andIDistributedCache(L2) behind a single API, with stampede protection and tag-based invalidation built in. A useful option when its supported serialization, invalidation and per-instance coordination fit the data.
// IMemoryCache
builder.Services.AddMemoryCache();
// IDistributedCache via Redis
builder.Services.AddStackExchangeRedisCache(opts =>
{
opts.Configuration = builder.Configuration.GetConnectionString("Redis");
opts.InstanceName = "queries:";
});
// HybridCache — wraps both
builder.Services.AddHybridCache(opts =>
{
opts.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10), // L2 TTL
LocalCacheExpiration = TimeSpan.FromMinutes(2) // L1 TTL
};
});The simple decision tree: if your data is small + hot + per-instance-OK, use IMemoryCache. If it must be shared, use an out-of-process IDistributedCache provider directly or a compatible HybridCache package with that provider registered. Without a usable L2 provider HybridCache is local-only. Compare key, serialization, invalidation and cancellation contracts before migrating an existing wrapper; package compatibility must be checked for your target framework.
Good enough to ship
• HybridCache for shared expensive query results.
• IMemoryCache for hot per-instance reference data.
• Choose an L2 provider from latency, operational cost, availability and existing infrastructure; SQL is a supported option, not inherently invalid.
Expert tier
• Custom IDistributedCache for niche backends.
• Distinguish HybridCache logical tag invalidation from an application-built pub/sub invalidation protocol.
• Evaluate provider-supported multi-region replication, conflict resolution and latency; active-active replication is not a strict-consistency guarantee.
Cache-key design: encode every variable, stability, cardinality
A cache key uniquely identifies a (request, context) tuple whose answer is stable for the TTL. The discipline is straightforward in principle and a constant source of bugs in practice.
Encode every variable that affects the answer. A dashboard query that varies by tenant, user role, date range and feature flag must have all four in the key:
// CanonicalKey is application code: typed length-delimited encoding + stable digest.
// Include EXACT normalized query instants; do not round the key but query exact values.
var key = CanonicalKey("dashboard-v3", tenantId, userId, authorizationVersion,
from.ToUniversalTime(), to.ToUniversalTime(), filters, flags);Missing any of these turns the cache into a correctness bug. The canonical IDOR-meets-cache disaster is var key = "dashboard" — another authorized caller can receive the first caller's private payload until replacement or expiry.
Stability across deploys. A cache key that includes serialiser version, framework version or assembly hash means every deploy invalidates the entire cache. That is sometimes desirable (after a schema change) and usually not. Prefer explicit version prefixes (v17:dashboard:...) that you bump intentionally.
Cardinality control. The number of distinct keys grows with the cardinality of the dimensions you encode. A (tenantId, userId) key with 100k tenants × 10k users per tenant means a billion distinct keys; your Redis bill goes vertical. Bucket high-cardinality dimensions only when the actual query semantics also use that bucket (e.g. a five-minute snapshot) and avoid keying on things that change every request (request id, current minute).
// Bad: includes each current timestamp (round-trip precision), not a reusable bucket
var key = $"q:{userId}:{DateTime.UtcNow:O}";
// Only valid if the query also uses this five-minute UTC snapshot.
// A time-bucket fragment: add tenant/projection/filter dimensions as required.
var now = DateTime.UtcNow; // capture once; avoid a midnight rollover between reads
var bucket = new DateTime(now.Year, now.Month, now.Day,
now.Hour, now.Minute / 5 * 5, 0, DateTimeKind.Utc);
var key = $"q:{userId}:{bucket:yyyyMMddTHHmm}";Leakage example: a cache keyed only by dashboard can return tenant A's payload to B. Derive tenant/user context from authenticated claims, authorize access before cache lookup and encode every authorization-dependent projection. Adding user ID is not a universal one-line fix: role revocation, tenant scoping, filters, locale and feature variants may also change the answer. Public countries data intentionally has no user key.
TTL strategies: absolute vs sliding, why sliding can serve stale forever
IMemoryCache and DistributedCacheEntryOptions support absolute and sliding expiration. HybridCacheEntryOptions 9.x instead exposes Expiration and LocalCacheExpiration (absolute durations), not SlidingExpiration:
- Absolute expiration — a hard ceiling: the entry expires N minutes after it is set, regardless of access.
- Sliding expiration — the timer resets on each access: an entry that keeps being read keeps living.
Sliding alone is a trap. If a popular entry is read constantly, it never expires, even if the underlying data changed an hour ago. The user-visible behaviour: a stale entry that never refreshes for some users. Always pair sliding with an absolute ceiling so even hot entries are forced to refresh:
var options = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15),
SlidingExpiration = TimeSpan.FromMinutes(5),
};This means: cold entries die after 5 minutes; warm entries survive at most 15 minutes regardless of access. Sliding-only does not bound data age; it can be valid for a deliberately idle-expiring session but is insufficient for a freshness contract. Absolute expiration is independent of hot/cold access patterns.
Illustrative TTL starting points, not measured optima or universal freshness recommendations:
- Static reference data (countries, currencies): 24 hours.
- User profile / org settings: 5–15 minutes.
- Search results and dashboards: 1–5 minutes.
- Real-time data: do not cache, or 5–30 seconds with stale-while-revalidate.
- Auth tokens: let the identity SDK own refresh/cache semantics; token lifetime and safety skew vary.
Pick a number for a reason. TTL bounds cache-entry lifetime after insertion, not automatically data age since the authoritative change: source lag, a long-running fill and other cache layers can add age. Store a source revision/timestamp and enforce the total age budget when that distinction matters.
Eviction: size limits, memory pressure, SetSize
IMemoryCache is bounded by what you give it. Without a SizeLimit there is no implicit size cap: entry churn and expiration determine growth, and an unbounded workload can exhaust memory. ASP.NET Core does not automatically trim MemoryCache based on system memory pressure. The discipline:
// A dedicated singleton cache, owned/disposed by the application container.
// Do not impose a size contract on a shared framework cache you do not control.
public sealed class ResultCache : IDisposable
{
public MemoryCache Cache { get; } = new(new MemoryCacheOptions { SizeLimit = 100_000 });
public void Dispose() => Cache.Dispose();
}
// In service-registration code:
// builder.Services.AddSingleton<ResultCache>();
// On each write to ResultCache.Cache:
// cache.Set(key, value, new MemoryCacheEntryOptions {
// AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5), Size = 1 });SizeLimit is in units you choose. Common choices: 1 unit per entry (simple count), bytes (measure each entry's serialised size), or a custom weight (e.g. bytes × access frequency). Whatever you pick, every entry must set Size — an entry without Size is invalid for a size-limited cache and can throw. Shared library entries may not follow your units, which is why the dedicated cache matters.
An insertion exceeding the limit can be refused and compaction considers expiration, priority, access and expiration ordering; this is not a strict globally fair LRU guarantee. There is no fairness guarantee across tenants; a noisy tenant can monopolise the cache. For multi-tenant fairness, enforce per-tenant quotas within a global budget, or use a tested weighted policy. Creating an unbounded number of per-tenant caches simply moves the memory-growth problem.
IDistributedCache / Redis eviction is configured at the Redis instance level (maxmemory-policy set to allkeys-lru or volatile-lru for most use cases). Redis LRU is approximate. Consider allkeys-lru for a recency-skewed cache; volatile-lru can evict only TTL-bearing keys and behaves like noeviction if none qualify. Measure hit rate and rejected writes under your provider's supported policies. Prefer separating persistent authoritative data from an evictable cache.
Cache invalidation strategies: TTL, explicit, version prefix bumping
The four invalidation approaches you should know:
- TTL only — entries expire on a clock. Simple; eventual freshness; acceptable when staleness windows match business need. Use as the baseline.
- Explicit on write —
cache.Remove(key)(orcache.RemoveByTagAsync(tag)forHybridCache) every time a write touches the underlying data. Tighter consistency; couples write paths to the cache. Other instances can still hold L1 values. Use a bounded freshness policy or a designed coordination/version protocol; invalidating L2 alone does not synchronously invalidate every L1. - Tag-based invalidation —
HybridCachelets you set entries with tags (the separatetags:argument, not an entry-options property) and invalidate all entries with a tag (cache.RemoveByTagAsync("tenant:42")). The canonical pattern for "one write invalidates many derived caches". - Version-prefix bumping — every key starts with a version (
v17:dashboard:...). On a write, the writer bumps a stored version (v17 → v18); subsequent reads miss and repopulate. Old keys age out via TTL. The prefix increment and data write need a consistency protocol; a cached get-plus-set counter is not atomic. Old keys still consume space until expiration.
// Tag-based with HybridCache
await cache.GetOrCreateAsync($"user:{id}",
factory: async ct => await repo.GetAsync(id, ct),
tags: new[] { $"user:{id}", "users:all" });
await cache.RemoveByTagAsync($"user:{id}"); // on writeWithin the authoritative database transaction:
authorize and update tenant dashboard
increment that dashboard's data revision atomically
commit data + revision together
Reader:
read a permitted authoritative revision
key = (tenant, dashboard, projection, revision)
load data belonging to that same revision, or retry on mismatch
cache that immutable version with a bounded TTLA separately cached revision can itself be stale; reading revision r and then data r+1 without a consistency check mislabels the result. A distributed INCR may make a counter atomic but still does not transact with a SQL update. When strict freshness is required, use the authoritative transaction/version check or bypass caching instead of calling a best-effort invalidation “strict consistency”.
Good enough to ship
• TTL by default.
• Await RemoveByTagAsync on relevant write paths; handle failures and bound other instances' L1 staleness.
• Document each cache's freshness contract.
Expert tier
• Revisioned keys when their authoritative revision/data read protocol is defined; not a standalone strict-consistency guarantee. • Pub/sub fan-out for L1 invalidation across instances. • Per-tenant invalidation channels.
Stampede protection: single-flight, probabilistic early refresh
A cache stampede happens when an entry expires under load: many concurrent callers all miss, all call the origin, all populate the cache. The origin sees a burst of duplicate work; on a marginal downstream this can be the difference between "recovers in 1 second" and "stays down".
The fixes:
- Single-flight (LazyCache,
HybridCache) — the cache library tracks in-flight factories per key. When compatible callers on the same HybridCache instance/key miss simultaneously, one factory runs and others await its result. Other processes may execute their own factories.HybridCachedoes this natively;LazyCacheprovides it on top ofIMemoryCache. SemaphoreSlimper key — the manual equivalent: a per-keySemaphoreSlimguards the factory. Easy to get slightly wrong (leaking semaphores, blocking the wrong threads). Prefer the library.- Probabilistic early refresh — choose a refresh probability that increases toward expiry (some designs also use measured recomputation cost). It spreads expected refresh work; it does not guarantee one refresh or no expiry-time miss. Pair it with bounded coordination/admission and test sparse as well as bursty traffic. TTL jitter across many keys addresses synchronized expirations, not same-key ownership.
// HybridCache — single-flight built in
public ValueTask<User?> GetUser(Guid id, CancellationToken ct) =>
cache.GetOrCreateAsync($"user:{id}",
factory: async tok => await repo.GetAsync(id, tok),
cancellationToken: ct);// Manual SemaphoreSlim-per-key (when you cannot use HybridCache)
private static readonly ConcurrentDictionary<string, SemaphoreSlim> _gates = new();
public async Task<T> GetOrCreateSingleFlightAsync<T>(string key, Func<CancellationToken, Task<T>> factory, CancellationToken ct)
{
if (_cache.TryGetValue<T>(key, out var hit)) return hit!;
var gate = _gates.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(ct);
try
{
if (_cache.TryGetValue<T>(key, out hit)) return hit!;
var value = await factory(ct);
_cache.Set(key, value, TimeSpan.FromMinutes(5));
return value;
}
finally { gate.Release(); }
}The manual helper above shares gates only inside this process, has no distributed ownership and retains every gate. Restrict it to a finite trusted keyspace and one value type per key. Cancellation before acquiring a gate must not release a permit; cancellation/failure after acquiring it runs finally. Do not remove a gate while waiters may still reference it.
Cache warmup: BackgroundService, PeriodicTimer, canonical request sets
For caches that take a noticeable time to populate (a 2-second Kusto query that you want to keep hot), bounded pre-population can shift cold-fill work away from interactive requests, but unused keys and repeated per-replica warmup can increase total origin work. The pattern:
public class DashboardWarmup(HybridCache cache, IDashboardService svc, ILogger<DashboardWarmup> log)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
do
{
var requests = await svc.GetCanonicalRequestsAsync(stoppingToken);
await Parallel.ForEachAsync(requests,
new ParallelOptions { MaxDegreeOfParallelism = 4, CancellationToken = stoppingToken },
async (req, ct) =>
{
try
{
await cache.GetOrCreateAsync(req.Key,
factory: async tok => await svc.ComputeAsync(req, tok),
cancellationToken: ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
log.LogWarning(ex, "Warmup failed for {Key}", req.Key);
}
});
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
}- A canonical request set is the small list of queries you know users will hit (the top dashboards, the default views, the popular reports).
PeriodicTimeris the right primitive for "wake up every N seconds" — it coalesces ticks while work is running and observes cancellation; it is not a no-drift real-time scheduler.MaxDegreeOfParallelismcontrols how aggressively warmup hits the origin; tune so warmup never starves real traffic.
This loop fills missing entries; GetOrCreateAsync does not refresh an existing hot entry every minute. Readiness gating requires an explicit warmup-completion state and a bounded required-key set; AddHostedService alone does not wire that state. Use the same key, tags, TTL and factory as normal reads. Resolve scoped repositories inside an operation scope rather than capturing them in a singleton hosted service. Both warmup sketches catch individual compute failures, not catalog retrieval failures. Define bounded retry/backoff and health reporting for the latter; do not silently mark readiness complete after a failed fill. Four workers per replica can still mean 4 × replica-count origin operations.
Where caching lives in the stack
The same data can be cached at any of five layers. Combining them safely is the architecture choice:
- Browser —
Cache-Control,ETag,If-None-Match. A fresh reusable response avoids a server request; revalidation still contacts the server. - CDN / edge (Front Door, Cloudflare) — public, idempotent GETs cached at the geographic POP. Network latency depends on client/POP distance; it is not universally microseconds.
- App-level distributed (Redis) — shared across app instances; network/serialization cost; the cache you reach for first.
- App-level in-process (
IMemoryCache) — avoids a network hop; per-instance; measure lookup and allocation cost rather than assuming a universal latency. - Database-side caches — engine-specific buffer/plan/result caches. SQL Server Query Store retains query/plan/runtime history; it is not a query-result cache. Cosmos integrated cache is a separate configured feature, not every Cosmos response.
A combined stack might look like:
HybridCache 9's LocalCacheExpiration contract does not exceed the remaining overall cache lifetime when loading from its external tier. The following counterexample is about independent outer caches, not a claim that HybridCache resets L2 lifetime on every L1 fill. TTL inequalities do not compose a freshness guarantee. If an L2 value is already nine minutes old when copied to an independent five-minute outer cache, it can be served at age fourteen minutes unless remaining freshness/validators are propagated. Inner invalidation does not purge a CDN/browser. Define a total age budget and each layer's invalidation/revalidation contract. Common bug: CDN caches for 1 hour, app cache invalidates on write; user gets a CDN-served stale response for the rest of the hour.
Anti-patterns
A short list of caching mistakes that bite forever:
- Caching mutable references — caching a
List<T>that one caller mutates while another reads it. Cache immutable snapshots (records, frozen collections) only. - Caching failures — caching a 500 response or an exception. The first failure becomes a permanent failure for the TTL. Do not cache transient failures as successful data. Deliberate short negative caching (for a confirmed not-found) needs its own permission scope and TTL.
- Unscoped keys leaking data across users/tenants — authorize before lookup and encode all answer-dependent tenant/principal/projection/filter dimensions. Truly public shared data has a different key contract.
- Confusing cache-aside and write-through — cache-aside reads miss → origin → populate; writes update origin then invalidate/update. Write-through routes writes through a layer that synchronously persists to origin before acknowledging. It does not imply an atomic Redis+SQL transaction. Write-behind acknowledges before asynchronous persistence and has a separate durability/loss contract.
- No TTL — eternally cached entries become stuck-state bugs that survive across deploys and feature flags.
- Cache without metrics — you have no idea what your hit rate is; you have no alert when it drops. Emit
cache.hits/cache.missesand alert on hit-rate degradation.
Failure example: an upstream returns a transient 503; storing it as a successful one-hour entry amplifies the outage after origin recovery. Prefer explicit failure handling or a deliberately labelled stale fallback. A short cached 404 for an immutable missing object is a different negative-caching policy and must still respect tenant authorization.
Read-fill races and cache-outage capacity
Even “write then remove” has a stale-fill race: reader R loads version 1, writer W commits version 2 and removes the cache, then R stores version 1 after removal. A TTL bounds how long that inserted entry can remain reusable; total data age also includes the delayed fill/source lag. It does not eliminate the race. Versioned keys with a consistent revision/data read, a tested invalidation protocol, or bypass for strongly consistent reads addresses this; a longer TTL makes the race worse.
If Redis fails, some L1 hits may remain available, but blindly bypassing every miss sends all traffic to origin. Bound bypass concurrency and deadline, permit only contractually safe stale results and shed excess work with an explicit unavailable response. Test provider-specific exception behavior instead of assuming HybridCache turns every L2 failure into transparent origin fallback. Redis persistence/replication is a separate configuration; a cache should not be the only copy of authoritative data.
Worked examples
Example 1 — HybridCache for a dashboard query
public class DashboardService(HybridCache cache, IKustoClient kusto)
{
public async Task<DashboardPayload> GetAsync(Guid tenantId, Guid dashboardId, CancellationToken ct)
{
var key = $"v3:dashboard:{tenantId}:{dashboardId}";
return await cache.GetOrCreateAsync(
key,
factory: async token => await kusto.RunDashboardAsync(tenantId, dashboardId, token),
options: new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(5),
LocalCacheExpiration = TimeSpan.FromMinutes(1)
},
tags: new[] { $"tenant:{tenantId}", $"dashboard:{dashboardId}" },
cancellationToken: ct);
}
public ValueTask InvalidateDashboardAsync(Guid dashboardId) =>
cache.RemoveByTagAsync($"dashboard:{dashboardId}");
}- Versioned key prefix (
v3:) — bump intentionally on schema change. - Tags allow logical invalidation. This example assumes globally unique dashboard IDs and a payload identical for all authorized readers of that dashboard; otherwise include user/projection and tenant-scoped tags. Authorize before invoking the method. Other servers' L1 caches are not synchronously purged.
LocalCacheExpirationcaps this local entry lifetime at one minute, within the remaining overall lifetime. The five-minute overall duration is not proof of five-minute freshness since a database change.
Example 2 — Per-key single-flight without HybridCache
public class CacheService(IMemoryCache mc, ILogger<CacheService> log)
{
private static readonly ConcurrentDictionary<string, SemaphoreSlim> _gates = new();
public async Task<T> GetOrCreateAsync<T>(string key, TimeSpan ttl, Func<CancellationToken, Task<T>> factory, CancellationToken ct)
{
if (mc.TryGetValue<T>(key, out var hit)) return hit!;
var gate = _gates.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(ct);
try
{
if (mc.TryGetValue<T>(key, out hit)) return hit!;
var value = await factory(ct);
mc.Set(key, value, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = ttl,
Size = 1
});
return value;
}
finally
{
gate.Release();
// Do not remove gates while callers may still hold references; this finite-key teaching example retains them.
}
}
}- One semaphore per key; the first caller computes, the rest wait.
Size = 1is mandatory if theIMemoryCachehas aSizeLimit.- The dictionary grows; restrict the keyspace or use a lifecycle-aware tested implementation. Naive timer removal can break mutual exclusion.
Example 3 — Cache warmup BackgroundService
builder.Services.AddHostedService<DashboardWarmup>();
public class DashboardWarmup(
HybridCache cache,
IDashboardCatalog catalog,
IKustoClient kusto,
ILogger<DashboardWarmup> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
do
{
var canonical = await catalog.GetHotDashboardsAsync(top: 50, ct);
await Parallel.ForEachAsync(canonical,
new ParallelOptions { MaxDegreeOfParallelism = 4, CancellationToken = ct },
async (d, tok) =>
{
try
{
await cache.GetOrCreateAsync($"v3:dashboard:{d.TenantId}:{d.Id}",
factory: async token => await kusto.RunDashboardAsync(d.TenantId, d.Id, token),
options: new() { Expiration = TimeSpan.FromMinutes(5), LocalCacheExpiration = TimeSpan.FromMinutes(1) },
tags: new[] { $"tenant:{d.TenantId}", $"dashboard:{d.Id}" },
cancellationToken: tok);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
log.LogWarning(ex, "Warmup failed for {Key}", d.Id);
}
});
} while (await timer.WaitForNextTickAsync(ct));
}
}- The catalog returns the top-N "hot" dashboards — the small canonical set worth pre-computing.
- Parallel warmup with bounded concurrency keeps origin load predictable.
- Logs non-cancellation exceptions for individual compute operations. Catalog retrieval is outside that catch; readiness state, dependency scopes and bounded retry are additional application code, not supplied here.
Example 4 — CDN-friendly responses with Cache-Control and ETag
// ASP.NET Core 8; Microsoft.Net.Http.Headers.EntityTagHeaderValue
app.MapGet("/api/countries", async (ICountryRepo repo, HttpContext ctx) =>
{
var countries = await repo.GetAllAsync();
// Stable ordering/options are part of this representation contract.
var bytes = JsonSerializer.SerializeToUtf8Bytes(countries);
var digest = Convert.ToHexString(SHA256.HashData(bytes));
var etag = new EntityTagHeaderValue($"\"{digest}\"");
ctx.Response.GetTypedHeaders().ETag = etag;
ctx.Response.Headers.CacheControl = "public, max-age=300, stale-while-revalidate=600";
var validators = ctx.Request.GetTypedHeaders().IfNoneMatch;
if (validators is not null && validators.Any(candidate =>
candidate.Tag == "*" || candidate.Compare(etag, useStrongComparison: false)))
return Results.StatusCode(StatusCodes.Status304NotModified);
return Results.Bytes(bytes, "application/json; charset=utf-8");
});This public, non-personalized representation uses GET's weak comparison for If-None-Match and accepts validator lists or *; it returns cache policy and ETag on 304 too. The hash covers the exact serialized bytes returned, not GetHashCode on a mutable list. Validate/handle malformed headers through the HTTP stack's normal bad-request path. Production middleware must also account for negotiated encodings/representations and Vary; use a supported response/output-caching implementation rather than expanding an ad hoc conditional stack indefinitely.
- max-age 300 permits five minutes of HTTP freshness, accounting for HTTP age; stale-while-revalidate 600 permits an additional ten-minute stale window where supported. RFC 5861 does not guarantee traffic arrives to trigger revalidation. This is a general HTTP example, not a tested Azure Front Door contract. Microsoft's Front Door caching documentation (checked 9 October 2026) supports Last-Modified revalidation and explicitly says ETag is not supported. Do not assume this ETag handler or SWR directive alone implements Front Door revalidation; validate the chosen edge policy and add a correct Last-Modified/If-Modified-Since implementation or supported middleware where needed.
- A 304 saves response transfer, not necessarily the origin query/serialization above. Cheap version metadata can avoid recomputing the full body.
- Do not put user-specific countries/pricing/permission results behind public caching. Authentication, Vary and Cache-Control must match the representation's real privacy contract.
Hands-on exercises
-
In-process speedup. Wrap a 100ms-slow endpoint in
IMemoryCachewith a 5-minute TTL. Measure warm/cold distributions, key reuse and origin calls; do not assume 99% sub-millisecond end-to-end responses.- You are done when measured hit/miss classes and origin-call counts agree; their latency distributions may overlap.
-
Add a distributed tier. Run Redis locally (
docker run --rm -p 127.0.0.1:6379:6379 redis:7), addAddStackExchangeRedisCache, and confirm the cache survives a process restart.- You are done when restarting the app (not Redis) retains valid L2 entries; Redis restart durability is a separate configured/tested property. Keep the disposable local port loopback-only.
-
Migrate to
HybridCache. Replace the manual L1+L2 implementation withHybridCache. Measure L1 and L2 latency/cost and verify data equivalence; no fixed latency is promised.- You are done when both hit paths are observable in custom metrics.
-
Stampede demonstration. Force 100 concurrent callers to miss an empty cache. Count factory invocations with and without
HybridCache. Confirm one compatible factory invocation on one HybridCache instance, then repeat across two processes and record that fleet-wide single-flight is not guaranteed.- You are done when synchronized-start factory counts agree with the documented per-instance scope; a chart alone is not a concurrency proof.
-
Tag-based invalidation. Cache user data with
tags: new[] { $"user:{id}" }. On user update, callRemoveByTagAsync($"user:{id}"). Verify logical invalidation and the separate behavior of another process's L1; do not infer synchronous byte deletion.- You are done when invalidated reads repopulate in the tested scope and another instance's remaining L1 lifetime is recorded.
-
Warmup at startup. Add the
DashboardWarmupBackgroundServicefrom Example 3. Gate the readiness probe on warmup completion. Verify readiness for the bounded canonical set; other keys or later cache failures can still be cold.- You are done when the declared canonical keys are ready (or readiness fails explicitly); an uncatalogued key remains a tested cold-miss case.
Self-check questions
- What is the difference between absolute and sliding expiration, and why does sliding-only fail to bound freshness?
- What is a cache stampede, and what are three ways to prevent one?
- Why must every cache entry have a
Sizeset ifMemoryCache.SizeLimitis configured? - Compare TTL-only invalidation, explicit invalidation, tag-based invalidation, and version-prefix bumping. Give one use case for each.
- Why is "caching failures" an outage amplifier?
- Explain tenant/projection cache isolation and why adding user ID alone is not a complete authorization fix.
- What's the difference between cache-aside and write-through?
- When does in-process caching beat Redis, and vice versa?
- Why can data ages accumulate across cache layers even when their TTLs appear ordered?
- What is
stale-while-revalidategood for? - What primitives does
HybridCachegive you that hand-rolled L1+L2 does not? - How would you alert on a cache hit-rate regression?
Worked assessment answers
- Absolute versus sliding. Absolute expiration caps entry lifetime after insertion; sliding caps idle time. With absolute 15m/sliding 5m and accesses each minute, the entry expires by minute 15. Sliding-only may remain indefinitely hot and stale, although it is legitimate for an explicit idle-session policy. HybridCache 9.x exposes absolute duration options, not SlidingExpiration. Source lag is outside that entry timer.
- Stampede controls. A hundred synchronized misses can issue a hundred fills. Per-key single-flight coalesces compatible callers within one instance; TTL jitter spreads expiration of different keys; probabilistic early refresh spreads expected same-key refresh work. Only an ownership mechanism provides single-flight in its specified scope, and none of these automatically coordinates the fleet.
- Size units. SizeLimit uses application-chosen units, so every admitted entry must declare Size in those same units. Missing Size is invalid rather than zero-cost. Use a dedicated cache; a count limit of 100,000 is not a byte or memory-pressure limit.
- Four invalidation choices. TTL-only fits a country-list snapshot whose documented age is acceptable. Explicit removal fits a known product key after a committed edit, subject to stale-fill races. Tags group a user's derived views for logical invalidation, not immediate byte removal everywhere. Revisioned keys fit immutable dashboard revisions only when the authoritative revision and data are read consistently; none creates a cross-store transaction.
- Failure amplification. Cache a transient 503 as successful data for an hour and callers can keep failing after the origin recovers. A deliberately short, authorized not-found entry is a separate negative-cache policy, not permission to cache all failures. Recovery tests must include a failure followed by success.
- Isolation. Authenticate and authorize before lookup, then encode every answer-dependent tenant/principal/projection/filter/version dimension. A key containing only user ID can still collide across tenants or preserve revoked permissions; public country data intentionally uses a shared key. A cache key is not an authorization check.
- Write paths. Cache-aside reads miss → origin → populate; writes commit origin then invalidate/update and can race with a reader. Write-through synchronously persists through the cache layer before acknowledging. Write-behind acknowledges before asynchronous persistence and needs its own loss/reconciliation contract. Redis plus SQL is not atomic just because both writes were awaited.
- L1 versus L2. In-process hits avoid network/serialization and suit small local snapshots, but vanish on restart and differ across replicas. An out-of-process L2 can share values across app restarts/replicas, with network, serialization and availability costs. Compare measured workload distributions and capacity, not universal latency numbers.
- Age composition. An independent outer cache storing a nine-minute-old value for five more minutes can serve fourteen-minute-old data. Propagate remaining freshness/source version or enforce a total age check. HybridCache's own local expiration does not exceed remaining overall lifetime; that guarantee does not automatically cover a browser or CDN.
- SWR. Stale-while-revalidate allows a supporting HTTP cache to serve stale data during a bounded revalidation window. It improves availability/latency only when the privacy and age contract permits it. Unsupported directives, absent traffic and failed validation do not promise a refresh; Front Door's documented validators differ from this generic ETag example.
- Hybrid primitives. A common L1/optional-L2 API, serialization, logical tags and per-instance compatible-call stampede coordination replace some wrapper code. It still needs a configured provider, bounded keys, freshness/isolation policy and tested outage behavior. Other servers' L1s are not synchronously purged.
- Alerting. Compute hits/(hits+misses) over an explicit window with a minimum request volume; zero requests mean unknown, not 100% hits. Compare equivalent routes/key versions, then correlate miss/fill errors and origin saturation with user SLIs. An 80%-for-15m threshold is a candidate to calibrate, not a universal early warning.
Exercise 1 solution: Record a cold miss, repeated hits, expiry and a deliberately different key. Assert origin-call counts and returned payloads, then compare warm/cold latency distributions for the 100ms synthetic origin. Do not require sub-millisecond end-to-end latency or a visibly bimodal histogram.
Exercise 2 solution: Bind disposable Redis to loopback, populate a uniquely scoped key, restart only the application and read before its TTL expires. A retained L2 value demonstrates app-restart sharing; restarting Redis without persistence or waiting past TTL is a negative control, not a failed durability promise.
Exercise 3 solution: Keep identical key/projection/serialization semantics when migrating; count origin fills, L1 hits and L2 reads, and compare payloads. Test a cold process against a warm L2 and an expired entry against origin. These are integration acceptance steps, not timings proved by the local models.
Exercise 4 solution: Use a barrier and a deliberately blocked factory so all 100 callers overlap before release. For one compatible instance/key, expect one active factory and equal results; use different keys and two processes as controls. Also cancel a waiter and fail a fill, checking that later requests can recover. A sequential loop cannot establish concurrent stampede protection.
Exercise 5 solution: Tag two related keys and one unrelated key; await invalidation, then confirm the related entries logically miss while the unrelated entry remains valid. Repeat with a second process that already holds L1 entries and record its bounded stale behavior. Do not test physical deletion as the tag contract.
Exercise 6 solution: Track success for a finite required-key set; fail readiness on a required fill error or startup deadline. Match normal-read key, tags and TTL, bound per-replica and fleet load, and test an uncatalogued key still misses. Hosted-service registration and a one-minute timer alone do not implement readiness or force refresh of existing hits.
Worked outage: normal 1,000 RPS at 90% hits sends 100 RPS to origin. Losing all caches can send 1,000 RPS before retries. If origin can safely handle 200, a bounded bypass admits at most that budget and sheds/degrades the rest; “never return 500” is not a capacity proof.
Azure's published retirement schedule (rechecked 9 October 2026) retires Azure Cache for Redis Enterprise/Enterprise Flash on 31 March 2027, and Basic/Standard/Premium on 30 September 2028. Evaluate the supported migration path to Azure Managed Redis, feature/network/authentication differences and cost before adopting a legacy service for a new project. The Redis cache semantics taught here do not depend on that old product name.
High-signal resources
Official docs
- HybridCache in ASP.NET Core — package APIs and documented limitations, not a universal default.
- In-memory caching (
IMemoryCache). - Distributed caching (
IDistributedCache). - Response caching middleware.
- StackExchange.Redis docs.
Books or courses
These are optional reading leads, not evidence for the API claims above. Author/edition/chapter attributions were not independently verified in this review; verify the publisher listing before selecting an edition. No course duration or completion-time guarantee is asserted.
- Designing Data-Intensive Applications — commonly associated with Martin Kleppmann; edition authorship and any specifically named caching chapter remain unverified here. Look for the relevant replication/consistency treatment in the selected edition.
- Database Internals — attributed to Alex Petrov in this reading list; bibliographic details and chapter fit are unverified here.
Practitioner posts
- Microsoft eShop sample — optional repository lead; no particular revision or caching implementation was audited here.
- Marc Brooker blog — an index, not a verified article titled “caches, stampedes and load shedding”; locate and check the specific post before attribution.
- Google SRE book: load balancing in the datacenter — optional load-management reading; this review has not verified that it teaches the caching mechanisms above.
Weekly milestones
This is a suggested allocation, not a verified completion-time estimate; service access, setup and debugging can require additional time.
- Day 1. Read the HybridCache docs. Do exercise 1. Self-check questions 1–3.
- Day 2. Add Redis (exercise 2). Self-check questions 8 + 9.
- Day 3. Migrate to HybridCache (exercise 3) + stampede demo (exercise 4). Self-check questions 2 + 11.
- Day 4-5. Tag-based invalidation (exercise 5). Self-check questions 4 + 6.
- Day 6-7. Warmup + CDN headers (exercise 6 + Example 4). Self-check questions 5 + 7 + 10 + 12.
How it shows up in the capstone
A proposed capstone can use HybridCache with L1 in-process and L2 on a supported Redis deployment (evaluate Azure Managed Redis for new Azure projects). Treat 15-minute reference-data/2-minute local lifetimes and five-minute dashboard lifetimes as candidate budgets, not deployed measurements. User profiles require authorization/revocation policy distinct from public chart metadata. A (tenant, dashboard) key is sufficient only for an identical payload for every authorized reader; otherwise include projection/filter/principal dimensions. Tag invalidation does not synchronously purge every replica's L1.
A one-minute DashboardWarmup loop fills missing entries in the bounded top-50 set; it does not force a refresh of hot entries. Implement startup readiness state and failure/deadline behavior separately, and account for aggregate replica concurrency. Public non-personalized GETs may emit the generic Cache-Control/ETag headers in Example 4, but configure and test the actual edge cache: Front Door's documented revalidation uses Last-Modified, not ETag, and SWR behavior is not established by this lesson.
Custom metrics cache.hits, cache.misses and cache.factory_calls should be explicitly instrumented/exported. An 80%-for-15m hit-rate alert is only an illustrative starting point; require traffic volume, compare traffic/key changes and correlate origin load with user SLIs. No capstone deployment, cache-chaos result or alert delivery is claimed here.
Previous chapter → Ch 16 — Resilience patterns Next chapter → Ch 18 — CI/CD with Azure DevOps
Corrected contracts and failure analysis
HybridCache returns ValueTask from these operations; tags are a GetOrCreateAsync argument, not a HybridCacheEntryOptions property. Stampede coordination is per HybridCache instance, not a distributed lock across all servers. Tag invalidation is logical and does not synchronously delete every cached byte. Other servers' L1 caches are a separate consistency boundary.
A key must encode tenant, resource, filters, version and authorization-dependent projection. Caching the result of authorization as though it were public data risks cross-user leakage. Absolute TTL bounds entry lifetime, not arbitrary source age or a multi-layer freshness budget; sliding expiration alone can keep a hot stale entry alive indefinitely. A per-key semaphore dictionary is also a cache: unbounded keys leak memory, but removing a semaphore while callers still hold it can create two owners. Use a lifecycle-aware implementation rather than timer-pruning blindly.
Conditional HTTP requests parse lists and wildcard validators; simple string equality is only a pedagogical single-ETag case. Return ETag and cache policy on a 304 too. Do not cache personalized responses as public.
Boundary exercise with solution
Two server processes each miss their L1 for the same key. Does HybridCache guarantee one database query across the fleet?
Solution and reasoning
No. Its coordination scope is an instance. L2 can reduce misses, but concurrent misses may still run both factories. Make reads safe, provision origin capacity and use a deliberate distributed coordination protocol only when its failure modes are justified.
Source-backed review notes
- HybridCache library in ASP.NET Core | Microsoft Learn — accessed 2026-10-07. Exact supporting passage: “A HybridCache instance ensures that only one concurrent caller for a given key calls the factory method, and all other callers using the same instance wait for the result of that call. The cancellation token passed to the factory is canceled when all callers awaiting the shared operation have canceled. This coordination doesn't extend to other HybridCache instances, even if they use the same secondary distributed cache.”
Versioned source contracts
Official passages were checked on 7 October 2026; immutable tags are used where available, while service docs remain dated snapshots. The saved evidence records include exact passages, URL, source date/revision and snapshot hash. These references support the stated contracts, not an unperformed service deployment.
- cache-hybrid-api9 — HybridCache returns ValueTask; tags are a separate API argument.
- cache-hybrid9 — HybridCacheEntryOptions 9 has absolute duration options, not SlidingExpiration or Tags properties.
- cache-memory — Missing Size is invalid for a size-limited MemoryCache; use a dedicated cache.
- hybrid — HybridCache stampede coordination does not extend to other instances.
- http-conditional — GET If-None-Match uses weak validator comparison, including lists/wildcard.
- redis-retire — Basic/Standard/Premium retirement date; new deployment planning must consider migration.
Current service-specific qualifications
Checked 9 October 2026: Front Door caching documents Last-Modified-only revalidation, not ETag; HybridCacheEntryOptions at v9.0.0 limits local lifetime to the remaining overall lifetime; Redis eviction describes approximate LRU and volatile-policy eligibility; RFC 5861 defines the bounded stale-while-revalidate permission. These are source contracts, not observed deployment behavior.
Pause / Recall / Apply
Can you explain it without the page?
Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.
Stored in this browser only. No account, no sync. Clearing browser data removes your record.