Concepts
What you will learn: If a product page repeats a slow query, a cache can speed up responses and reduce database load. Learn where to keep the copy, how stale data may be, and how to handle updates or outages without showing one user another user’s data.
Before you start: A cache is a temporary copy of a result that can be rebuilt; the source of truth is the authoritative data store, often a database. Know that an application may run on several instances: memory inside one process is not automatically shared with the others.
Select a term with a dotted underline for its meaning, then continue reading from the same place.
#Choosing the cache layer
Repeating an expensive query increases and database load. A cache keeps a rebuildable copy in exchange for memory, complexity and staleness. Measure the bottleneck first and define an acceptable freshness window per use case.
| Type | Scope and trade-off |
|---|---|
| IMemoryCache | In-process objects without network or ; each instance is independent and restart loses entries. |
| IDistributedCache | A byte-storage and expiration contract; a Redis provider supplies shared storage with network and serialization costs. |
| HybridCache | Local L1 and optional L2 through IDistributedCache, with a unified and coordinated misses. |
| Output caching | Server-side responses controlled by explicit caching policies. |
| cache and | Browser or edge responses governed by Cache-Control and validators. |
Redis can outlive an API restart, but eviction, Redis restart or failover can still lose entries. A cache must be disposable and rebuildable from its source of truth. See the caching overview.
#Cache-aside with IMemoryCache and Redis
- A product page frequently reads a price. The first cache miss reads the source and retains a temporary copy; the next request uses it.
- A source update before expiry can leave the cached price stale. Define invalidation and the delay acceptable for that screen.
- At checkout, validate price and business rules against the operation's authoritative source. A display cache is not automatically authoritative for charging. Plan source capacity for cache outages.
With , the application checks the copy and loads the source on a miss. Prefer : mutating a shared object in memory can change what other requests observe.
// DI: builder.Services.AddMemoryCache();
using Microsoft.Extensions.Caching.Memory;
public sealed class CategoryReader(IMemoryCache cache)
{
public string GetLabel(int categoryId) =>
cache.GetOrCreate($"category:v1:{categoryId}", entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); // (1)
return LoadLabel(categoryId); // (2)
})!;
private static string LoadLabel(int id) => $"Category {id}";
}
// Package: Microsoft.Extensions.Caching.StackExchangeRedis
// redisConnection comes from configuration, not a source-controlled secret.
builder.Services.AddStackExchangeRedisCache(options =>
options.Configuration = redisConnection);
// Fragment in a service with injected IDistributedCache and repository.
var key = $"tenant:{tenantId:N}:product:v1:{productId:N}";
var json = await cache.GetStringAsync(key, ct); // (3)
if (json is null)
{
var product = await repository.LoadAsync(tenantId, productId, ct);
json = JsonSerializer.Serialize(product);
await cache.SetStringAsync(key, json, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
}, ct); // (4)
}
- (1) Absolute expiration bounds lifetime; sliding expiration alone can keep a hot key alive indefinitely.
- (2) The loader is illustrative; real code queries or computes. GetOrCreate does not guarantee one factory execution under contention.
- (3) Distributed values are serialized bytes or strings, not shared object references. The key includes a trusted tenant.
- (4) The application owns and population. The Redis fragment needs Caching.Distributed and System.Text.Json namespaces.
Bound memory, payload size and lifetime. With a memory-cache SizeLimit, every entry on that cache must specify a size; use a dedicated cache if other callers are outside your control.
#HybridCache since .NET 9: tags and stampede protection
The Microsoft.Extensions.Caching.Hybrid package centralizes miss handling and . A registered IDistributedCache supplies optional L2; without it, L1 and local protection still work. Coordination for a key is within one HybridCache instance, rather than a distributed lock across servers.
using Microsoft.Extensions.Caching.Hybrid;
builder.Services.AddHybridCache(options =>
options.DefaultEntryOptions = new()
{
Expiration = TimeSpan.FromMinutes(5),
LocalCacheExpiration = TimeSpan.FromSeconds(30) // (1)
});
// Service fragment; LoadCatalogAsync is a tenant-scoped database query.
var catalog = await cache.GetOrCreateAsync(
$"tenant:{tenantId:N}:catalog:v2",
async token => await LoadCatalogAsync(tenantId, token), // (2)
tags: [$"catalog:{tenantId:N}"],
cancellationToken: ct);
// After the database update has committed:
await cache.RemoveByTagAsync($"catalog:{tenantId:N}", ct); // (3)
- (1) A shorter local lifetime limits nearby staleness but is not a consistency guarantee.
- (2) Concurrent callers on one instance share a load for that key; pass the factory's supplied cancellation .
- (3) Tag invalidation is logical rather than enumerating and physically deleting every payload. Other instances can retain L1 values until expiry.
| Before | Since 9 |
|---|---|
| Manual get, deserialize, load, set and coordination | HybridCache GetOrCreateAsync, tags and local miss coordination |
Do not make correctness-critical rules depend on tag invalidation. Keep value type and options consistent per key, and version keys when schemas change. See the HybridCache reference.
#Read and write strategies
The strategy determines who populates the copy and when the source changes. No strategy automatically creates a spanning Redis and .
| Strategy | Path and trade-off |
|---|---|
| The application checks the cache and loads on a miss; simple but exposed to cold starts and races. | |
| Read-through | A cache or loader layer hides source loading from callers; convenient but coupled to its provider. |
| Write-through | A write layer updates source and cache before acknowledging; higher and an explicit two-write failure policy. |
| Write-behind | A buffering layer accepts changes before asynchronous persistence; requires durable buffering and recovery to avoid loss. |
| Refresh-ahead | Hot keys refresh before expiration; fewer misses at the cost of extra work and scheduling. |
HybridCache looks read-through to its caller and often implements application cache-aside. Define your terminology during an interview because usage varies.
Cache-aside read: GET cache -> miss -> SELECT source -> SET cache // (1)
Write path: UPDATE source -> COMMIT -> invalidate cache // (2)
- (1) A miss is a normal path, rather than an exception.
- (2) Commit first avoids loading before the update completes, but does not eliminate every stale-fill race.
#Invalidation and the stale-refill race
bounds copy lifetime relative to loading; explicit eviction, tags and events accelerate freshness. Versioned keys isolate old generations but require an authoritative version and TTL cleanup. If invalidation must be delivered, persist it through an with the update rather than using a fire-and-forget event.
A: cache miss; reads database version 4 // (1)
B: updates database to version 5; commits; evicts key // (2)
A: stores version 4 in cache after the eviction // (3)
- (1) The reader began before the writer but was delayed.
- (2) Eviction after commit alone cannot close this race.
- (3) Old data returns. Choose short TTL for tolerated staleness, version checks with conditional writes, or bypass for authoritative decisions.
#Stampedes, penetration and avalanches
| Problem | Cause and mitigation |
|---|---|
| Many requests miss one hot key: single-flight, refresh-ahead and permitted stale-while-revalidate. | |
| Penetration | Repeated nonexistent keys: input validation, short negative caching and rate limits; optionally a maintained Bloom filter. |
| Avalanche | Many expirations or a cache-wide outage: jitter, gradual warming and bounded fallback. |
Local single-flight still permits one load from each instance. Stale-while-revalidate needs a maximum stale age and bounded refresh work rather than indefinite stale service.
var baseTtl = TimeSpan.FromMinutes(5);
var jitter = TimeSpan.FromSeconds(Random.Shared.Next(0, 61)); // (1)
var ttl = baseTtl + jitter;
// Apply ttl when populating each independent key.
- (1) Expiring independent keys over five to six minutes reduces synchronization; keep the range within the freshness budget.
Negative caching must distinguish not-found from timeout or permission denial. A short TTL limits hiding newly created items, and bounded key space prevents attackers filling memory with arbitrary IDs.
#Output caching, HTTP caching and ETags
Data caching avoids a query or calculation. Output caching stores a complete response on the server. caching lets browsers or CDNs reuse responses under header rules. An ETag enables conditional validation and a bodyless 304 when the selected representation is unchanged.
builder.Services.AddOutputCache(options =>
options.AddPolicy("public-catalog", policy => policy
.Expire(TimeSpan.FromSeconds(30))
.SetVaryByQuery("page", "category")
.Tag("catalog"))); // (1)
var app = builder.Build();
// In authenticated apps, authentication and authorization go before output caching.
app.UseOutputCache();
app.MapGet("/catalog", () => Results.Ok(new[] { "Book", "Pen" }))
.CacheOutput("public-catalog"); // (2)
app.Run();
GET /products/42 HTTP/1.1
If-None-Match: "product-42-v7" # (3)
HTTP/1.1 304 Not Modified
ETag: "product-42-v7"
Cache-Control: public, max-age=60
- (1) The policy controls lifetime and query variation; the example is public without tenant or user variation.
- (2) Endpoints opt in; AddOutputCache alone does not cache everything. Invalidate tags through IOutputCacheStore.EvictByTagAsync.
- (3) The client already has a representation; compare its validator with the authorized representation before returning 304. The exchange is illustrative, with annotations rather than literal HTTP comments.
The default policy excludes authenticated requests and Set-Cookie responses. Overriding those protections requires complete . private excludes shared caches, no-store prohibits storage, and no-cache permits storage with revalidation. An ETag is not authorization, and does not isolate caches. See output caching.
#Safe keys and data that must remain authoritative
A key missing tenant or user identity can leak data. Derive the tenant from a trusted identity after authorization, and include representation dimensions such as language, currency, filters and version. Use normalized, bounded values rather than raw user URLs.
var key = $"invoice:{invoiceId}";var key = $"tenant:{tenantId:N}:user:{userId:N}:invoice:v2:{invoiceId:N}";
// Authorization and a tenant-scoped source query are still required. // (1)- (1) The key prevents namespace collisions but does not replace invoice ownership checks in the source query.
Never cache passwords, CVV values or raw credentials in public responses. Do not use stale stock or balances to authorize reservations or debits; displaying a timestamped snapshot is different from making an authoritative decision. Permissions and revoked sessions require an explicit revocation and lifetime policy. Cache personal data only when necessary, with appropriate encryption, and retention.
#Redis beyond caching and distributed locksBonus
Optional but valuable: not asked in every interview, but knowing it deepens your understanding and sets you apart.
Redis data structures support rate-limit counters, sets, sorted-set leaderboards, sessions, Pub/Sub and Streams. Pub/Sub is not a durable queue: an offline subscriber misses events. Streams support retained history and consumer groups under their retention configuration.
A Redis distributed lock is a time-bounded lease, not an eternal guarantee of one active worker. Each acquisition needs a unique , and release must atomically compare that token and delete the key.
Acquire: SET lock:catalog-import <unique-token> NX PX 30000 // (1)
Release: atomically compare token and delete via Lua // (2)
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
- (1) NX requires absence and PX supplies milliseconds; this is command notation, not a runnable C# program.
- (2) The script compares and deletes atomically; separate GET and DEL calls can delete another owner's replacement lease.
#Cache metrics and outage behavior
Hit ratio alone does not establish success. Many cheap hits can hide a few extremely expensive misses. Measure , origin load, evictions, payload size, stale age and failures; distinguish L1, L2 and negative hits.
hit ratio = hits / (hits + misses) // (1)
900 hits + 100 misses => 90% for that defined measurement window
cache timeout -> bounded fallback -> database concurrency limit // (2)
- (1) Define the window and layer, handle a zero denominator, and specify whether errors count as misses or a separate metric.
- (2) Unbounded fallback shifts the whole load to the database. Use timeouts, bulkheads, load shedding and circuit breaking where needed.
Test cold-cache, unavailable-cache and invalidation-failure behavior alongside warm-cache performance. Optional data can degrade or serve bounded stale copies; authoritative operations should fail safely rather than report misleading success.
Interview questions
No results
Check your reasoning
Why does this choice fit? When would it fail? What changes under higher load or a dependency failure?
18.1Why use a cache, and what does it cost?Junior
I use a cache when repeated reads or calculations create measurable or load and the result can tolerate a defined freshness window. It trades memory and operational complexity for less origin work. I still consider query optimization and indexes first, because caching can hide an expensive underlying path until a cold start or outage exposes it. I also define invalidation, capacity limits and fallback behavior before judging the change by its warm-cache latency.
18.2How do IMemoryCache and IDistributedCache differ?Junior
I use IMemoryCache for in-process values where independent instance copies are acceptable. It avoids network and costs but disappears on restart and is not shared. IDistributedCache represents an external byte store, commonly Redis, so instances can access the same cached value. That introduces serialization, network failures and deployment compatibility concerns. Neither abstraction makes cached data authoritative or guarantees it can never disappear. I design misses and rebuilds as normal behavior in both cases.
18.3What is cache-aside?Junior
I implement by checking the cache, loading the authoritative source on a miss, and storing a copy with a bounded lifetime. The application owns that flow and invalidates related entries after a committed write. It is a good fit for read-heavy data with tolerated staleness. However, concurrent misses can overload the source, and readers can repopulate old values around a write, so I also consider single-flight loading and a deliberate consistency policy.
18.4What does HybridCache add in modern .NET?Mid
I use HybridCache to simplify typed caching with local memory and an optional distributed second level. It handles , supports tags and coordinates concurrent loads for the same key within one HybridCache instance. That removes repetitive application code, but it is not a distributed lock across servers. Other instances can still load independently and retain local values until expiration. I therefore choose local and distributed lifetimes according to freshness requirements and do not use tags as a correctness boundary.
18.5How do you invalidate cached data?Mid
I combine a bounded with explicit invalidation after the source commits. Depending on the data, I use keys, tags, durable invalidation events or versioned key generations. I define the tolerated stale window rather than claiming eviction provides strong consistency. A reader can still fill an old value after a concurrent write, and another instance can hold a local copy. Correctness-critical decisions bypass stale copies or use an authoritative version check and atomic source operation.
18.6What is a cache stampede?Mid
I call it a when many requests miss the same popular entry and all recompute it together. I use single-flight loading to share that work, with refresh-ahead or bounded stale-while-revalidate where the business permits it. HybridCache coordinates loads locally, so I still account for multiple instances. jitter helps distribute different keys' expirations but does not alone prevent a burst against one missing key. I also bound origin concurrency to survive cold starts.
18.7How do penetration and avalanche differ?Mid
I distinguish penetration, where repeated requests target nonexistent keys, from an avalanche, where many entries expire together or the cache becomes unavailable. For penetration I validate inputs, apply rate limits and optionally cache a genuine not-found result briefly. For avalanches I spread expiration times, warm gradually and limit fallback concurrency. I never negative-cache a timeout as if the item did not exist, and I bound cache capacity so arbitrary input cannot create unlimited negative entries.
18.8Explain read-through, write-through and write-behind.Mid
I use read-through to mean a caching layer that loads the source transparently on a miss. Write-through routes writes through a layer that updates the authoritative store and cache before acknowledging. Write-behind acknowledges buffered changes before persisting them asynchronously. I clarify these definitions because terminology varies between products. None automatically makes Redis and one . Write-through needs a partial-failure policy, and write-behind requires durable buffering, recovery and an explicit tolerance for delayed persistence.
18.9How do output caching and HTTP caching differ?Mid
I use output caching to store complete responses on the server under application policies, including expiration, variation and tags. caching uses protocol headers to control browser and shared-cache reuse. An ETag supports conditional validation and a 304 response when the authorized representation is unchanged. I keep public and personalized content separate, preserve the output-cache defaults around authenticated requests, and make every representation dimension part of the variation policy before enabling shared response caching.
18.10What is the difference between no-cache, no-store and private?Junior
I do not read no-cache as a prohibition on storage. It allows storage but requires validation before reuse. No-store tells caches not to store the response, while private prevents shared caches from storing it but can still permit a browser cache. I choose the directive according to the sensitivity and reuse requirements, together with authorization and secure transport. These headers are cache instructions; they do not make exposed data confidential or erase copies already obtained by a client.
18.11How do you design multi-tenant cache keys safely?Senior
I derive tenant identity from an authenticated, authorized context and include it in every tenant-specific key. I add user identity when content is personalized, plus relevant language, currency, filter and -version dimensions. Values are normalized and bounded so attacker-controlled input cannot create collisions or unbounded key growth. The source query remains tenant-scoped, and authorization happens before serving cached data. I test cross-tenant access explicitly because a perfectly functioning cache can otherwise distribute another tenant's information very efficiently.
18.12What would you never use stale cached data to decide?Mid
I do not use a stale copy as the authority for stock reservation, balance debiting or a sensitive access decision without an explicit revocation model. Those decisions need an atomic operation or current validation at the source. A cached stock display can still be useful if checkout revalidates availability. I also avoid caching passwords, payment security codes or secrets in public responses. Personal data needs a justified purpose, correct partitioning, limited retention and protection at rest and in transit.
18.13Why can a distributed lock expire while work is still running?Senior
I treat a distributed lock as a lease with a deadline. A paused process or network interruption can exceed that deadline, allowing another worker to acquire the lock while the original later resumes. A unique and atomic compare-and-delete make release safer, but they do not stop stale writes. For correctness I use a monotonic fencing token that the protected resource enforces, or rely on database concurrency and constraints. Renewal improves availability but does not eliminate every failure window.
18.14What else can Redis do besides caching?Mid
I use Redis data structures for counters, rate limiting, session storage, sets and sorted-set leaderboards. Pub/Sub supports transient broadcast, while Streams can retain records and support consumer groups under configured retention. I choose the structure around the required delivery and recovery semantics. An individual atomic command does not make a multi-command workflow atomic, so related updates may require a or script. I also evaluate persistence, and eviction settings before storing anything that cannot simply be rebuilt.
18.15How do you know a cache is helping and can survive an outage?Senior
I compare end-to-end and origin load before and after caching, alongside hit ratio, evictions, payload sizes and cache errors. I separate local and distributed hits because their costs differ. Then I exercise cold starts, unavailable Redis and failed invalidation. Fallback must have timeouts and bounded database concurrency; otherwise a cache outage can become a database outage. I define whether each endpoint may serve bounded stale data, degrade its response or fail safely under those conditions.
Test yourself
Quiz answers and completed-topic marks are saved in this browser; they do not sync across devices.
Which layer shares serialized cached values across API instances?
What is the scope of HybridCache single-flight coordination?
Many independent keys expire at the same second. Which mitigation fits?
Repeated requests target nonexistent products. Which technique helps?
What does Cache-Control: no-cache mean?
Why is a tenant-aware cache key insufficient by itself?
Why is separate Redis GET then DEL unsafe for lock release?
900 hits and 100 misses give which hit ratio?
A writer commits then evicts, but an older reader fills afterwards. What happened?
Redis becomes unavailable. What protects the database?