Caching looks simple from a distance — store the answer, skip the work next time — and that's precisely why it produces some of the hardest architecture questions in a senior or architect loop. Every caching decision is really a consistency decision wearing a performance costume: how stale is acceptable, what happens when two writers disagree, and what happens to the whole system when the cache itself fails. These questions are aimed at engineers with 10 to 20 years of experience who have owned a caching layer in production, not just called GetOrCreateAsync, and expect you to reason about invalidation, stampedes and failure modes with the same rigor you'd apply to the database itself.
Q1 Compare cache-aside, read-through, and write-through/write-behind. When does each fit, and what failure mode does each introduce?#
Short answer: Cache-aside puts the caching logic in the application and is the most common pattern because it's the most flexible and the easiest to reason about; read-through and write-through push that logic into the caching layer itself, trading application simplicity for tighter coupling to a specific cache provider and different failure characteristics on the write path.
In cache-aside, the application checks the cache first, and on a miss, reads from the source of truth, populates the cache, and returns the value; writes go to the source of truth and either invalidate or update the cache entry directly. Its main failure mode is the classic race: if a write invalidates the cache and a concurrent read repopulates it from a slightly stale read of the source before the write lands, the cache can end up serving stale data indefinitely until the next TTL expiry or explicit invalidation. Read-through looks identical from the caller's perspective, but the cache provider itself owns the miss-then-populate logic behind a single API, which simplifies application code at the cost of depending on the cache technology supporting it natively. Write-through writes go to the cache first, which synchronously writes through to the source of truth before returning — this guarantees the cache and source of truth never diverge, at the cost of every write paying the source's latency inline. Write-behind (write-back) writes to the cache and returns immediately, asynchronously flushing to the source of truth later, which gives the best write latency but introduces a durability gap: a crash between the cache write and the deferred flush loses data that was already acknowledged to the caller.
| Pattern | Where logic lives | Write latency | Failure risk |
|---|---|---|---|
| Cache-aside | Application | Normal (writes go direct) | Stale cache from a lost invalidation race |
| Read-through | Cache provider | Normal | Same staleness risk, less app-level control |
| Write-through | Cache provider | Higher (waits on source write) | None on durability; cache and source stay aligned |
| Write-behind | Cache provider | Lowest | Data loss if the process crashes before the deferred flush |
What interviewers look for: naming the specific failure mode each pattern introduces, not just its performance characteristic — the durability gap in write-behind and the invalidation race in cache-aside are the two answers that separate someone who's read about caching from someone who's operated it.
- Follow-up questions: how would you make write-behind safe enough to use for something that matters, like an order total? What would push you toward read-through/write-through over cache-aside despite the tighter coupling?
Q2 How do you design cache invalidation for data that changes from multiple places?#
Short answer: Invalidate by explicit signal wherever you can — the write path that changed the data tells the cache directly — and fall back to TTL as a safety net for the paths you can't instrument, because a cache with only a TTL and no active invalidation is really just "eventually correct within N seconds," which is fine for some data and quietly wrong for other data.
The hard part is rarely the happy path where one service owns both the write and the cache. It's the data that changes from multiple places: a background job that bulk-updates rows directly in the database, a support tool that patches a record out-of-band, or a second service that owns part of the same aggregate. Each of those paths needs to either go through the same invalidation code the primary write path uses, or publish an event (a database change feed, an outbox-pattern message, a pub/sub notification) that any cache-owning service subscribes to and reacts to by invalidating the relevant keys. Tag-based invalidation, where supported, is a significant simplification here: rather than tracking every individual cache key a piece of data might be stored under, you tag entries when they're created and invalidate by tag when the underlying data changes — HybridCache's tag invalidation, for example, works as a logical "ignore anything cached before this moment for this tag" rule rather than a physical sweep-and-delete, which makes it cheap to call from any write path without needing to know every key currently cached for that tag. The remaining risk after tagging is topology: in a multi-instance service, invalidating a tag updates the current instance and any shared secondary (distributed) cache, but doesn't reach into another instance's private in-process cache — that gap has to be closed either by keeping in-process TTLs short relative to your staleness tolerance, or by broadcasting invalidation across instances explicitly.
What interviewers look for: recognition that invalidation is a distributed-systems problem the moment more than one writer or more than one instance is involved, and a concrete plan for the paths that don't go through your primary write code (bulk jobs, out-of-band fixes) rather than assuming they don't exist.
- Common mistakes: relying entirely on TTL for data with a real staleness requirement, discovering the gap only when a support ticket shows a customer looking at data that was fixed an hour ago; forgetting that a background bulk job bypasses application-level invalidation entirely unless deliberately wired in.
- Follow-up questions: how would you invalidate a cache entry that depends on the join of two tables, where either can change independently? What's your fallback if the invalidation event itself is lost?
Q3 What is a cache stampede, and how do you prevent one? How does HybridCache solve this differently from a hand-rolled solution?#
Short answer: A cache stampede happens when a popular key expires (or the cache is cold on startup) and many concurrent requests all miss at once, all fall through to the source simultaneously, and overwhelm it with duplicate work — the fix is ensuring only one caller actually does the recompute while the rest wait for and share its result, which is exactly what HybridCache provides out of the box.
A hand-rolled cache built directly on IDistributedCache or IMemoryCache doesn't protect against this by default: a miss is just a miss, and if a thousand requests land in the same window after a hot key expires, a thousand database queries or a thousand calls to a downstream API fire at once. The classic fix is a per-key lock or semaphore around the recompute — the first caller to miss acquires it and populates the cache, subsequent callers either wait on the same lock and then read the now-populated cache, or use a double-checked-locking pattern — but this is easy to get subtly wrong (lock scope too broad and you serialize unrelated keys, or too narrow and you don't actually prevent duplicate work) and is one more piece of concurrency-sensitive code to maintain per cache. HybridCache.GetOrCreateAsync builds exactly this coordination in: for a given HybridCache instance, only one concurrent caller for a given key ever invokes the factory delegate, and every other concurrent caller for that same key awaits the same in-flight operation and receives its result once it completes, with no separate locking code required from the application. A subtlety worth stating explicitly: this coordination is scoped to a single HybridCache instance's in-process calls, so it doesn't prevent multiple server instances from independently missing and recomputing at the same moment — for that, the shared L2 (distributed) cache still matters, since once any one instance populates it, the others will hit rather than miss on their next GetOrCreateAsync call.
public class CatalogService(HybridCache cache)
{
public Task<ProductDetails> GetProductAsync(Guid productId, CancellationToken ct) =>
cache.GetOrCreateAsync(
$"product:{productId}",
async token => await LoadFromCatalogServiceAsync(productId, token),
cancellationToken: ct);
}What interviewers look for: understanding stampede prevention as request coalescing specifically (not just "add a TTL" or "cache more aggressively"), and knowing that HybridCache's protection is per-instance, which matters for reasoning about a multi-instance deployment's worst case.
- Follow-up questions: what happens to the cancellation token passed into the shared factory call if one of the waiting callers cancels but others don't? How would you stagger TTLs for a set of related hot keys to avoid them all expiring in the same instant?
Q4 Walk through HybridCache's architecture. What does it give you over IDistributedCache/IMemoryCache directly, and what are its current limits?#
Short answer: HybridCache (the Microsoft.Extensions.Caching.Hybrid package) is a two-level cache by design: it always maintains a fast in-process (L1) cache, and layers an optional out-of-process (L2) IDistributedCache — Redis, SQL Server, Postgres — on top, unifying both behind one API that adds stampede protection, tag-based invalidation and serialization handling that neither IMemoryCache nor IDistributedCache provides alone.
Register it with AddHybridCache(), inject HybridCache, and call GetOrCreateAsync. On a call, it checks the in-process cache first; on a miss there, it checks the distributed cache if one is configured; on a miss there too, it calls your factory delegate and populates both levels before returning. Even without any IDistributedCache registered, HybridCache still gives you in-process caching plus stampede protection, so it's a reasonable default even for a single-instance service, with the L2 layer added later as a pure configuration change rather than a code change. Serialization is handled for you: string and byte[] are special-cased, and everything else uses System.Text.Json by default, with AddSerializer/AddSerializerFactory available to plug in a type-specific or general-purpose alternative such as protobuf when you need tighter control over payload size or cross-language compatibility. Per-entry behavior is controlled through HybridCacheEntryOptions, which separates the overall (and L2) expiration from the in-process expiration:
var entryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(30), // L2 (distributed) lifetime
LocalCacheExpiration = TimeSpan.FromMinutes(5), // L1 (in-process) lifetime, typically shorter
};
var product = await cache.GetOrCreateAsync(
$"product:{id}", async ct => await LoadProductAsync(id, ct), entryOptions,
tags: ["catalog", $"category:{categoryId}"], cancellationToken: ct);Its documented limits matter for capacity planning: entries larger than MaximumPayloadBytes (1 MB by default) aren't cached and are logged instead, and keys longer than MaximumKeyLength (1024 characters by default) are likewise rejected from caching rather than silently truncated — both are configurable, but the defaults are a real ceiling worth knowing before you design a key or payload shape around them.
What interviewers look for: the two-level architecture stated precisely (always-on L1, optional L2), awareness of the two separate expiration settings and why they'd typically differ, and knowledge that the size and key-length limits are real, not theoretical.
- Follow-up questions: why would
LocalCacheExpirationtypically be shorter thanExpiration? What would you do differently for a payload that's legitimately larger thanMaximumPayloadBytes?
Q5 How do you design TTLs for a multi-tier cache — hot in-process data plus a shared distributed cache?#
Short answer: Set the distributed (L2) TTL from how stale the data is allowed to get, and set the in-process (L1) TTL shorter — often much shorter — because L1 is invisible to invalidation signals that only reach the shared cache, so a short L1 TTL is what actually bounds worst-case staleness on any given instance.
The reasoning starts from the invalidation gap described earlier: an explicit invalidation call typically clears an entry from the current instance's L1 and from the shared L2, but other instances' L1 caches keep serving their own copies until those copies expire on their own schedule. If L1's TTL is long — say, equal to L2's — an instance that already had a value cached can keep serving stale data for the full TTL after every other instance and the source of truth have moved on, purely because it never re-checked L2. Keeping LocalCacheExpiration short (seconds to a few minutes, depending on how expensive an L2 round trip is relative to your latency budget) bounds that worst case tightly, while a longer Expiration on the L2 tier still avoids hammering the source of truth for data that changes infrequently. Beyond that base pattern, match TTL to the actual volatility and cost of the data: reference data that changes rarely (currency codes, feature flag defaults, a product catalog's static attributes) can tolerate long TTLs on both tiers; per-user or frequently mutated data needs short TTLs, tag-based invalidation, or both; and data serving as an input to a financial calculation often shouldn't be cached at all, or should be cached with a TTL short enough that staleness is provably within an acceptable tolerance rather than merely "usually fine."
What interviewers look for: the specific insight that L1's TTL — not L2's — is what bounds staleness on instances that already hold a value, since that's the detail that separates a real multi-tier design from one that only works correctly in single-instance testing.
- Follow-up questions: how would you handle a piece of data that needs to be strongly consistent immediately after a specific user's own write, while still being cacheable for everyone else? Would you ever set
LocalCacheExpirationto zero, and what would that actually buy you?
Q6 Design a multi-level (L1/L2) caching architecture for a service running on multiple instances. What goes in each level, and what breaks if you get the levels backwards?#
Short answer: L1 (in-process memory) holds the hottest, smallest working set and trades consistency freshness for the lowest possible latency and zero network cost; L2 (a shared distributed cache) holds the broader, shared working set and trades a network round trip for consistency across every instance — get that backwards and you either pay network latency for data that didn't need it, or serve inconsistent data you thought was centrally controlled.
L1 should hold data that's genuinely hot (accessed on a large fraction of requests), fits comfortably in memory without pressuring the rest of the process's working set, and can tolerate the staleness window discussed above — think a parsed configuration blob, a small reference-data table, or the specific handful of product records driving a flash-sale landing page. L2 should hold everything that benefits from being computed once and shared across every instance rather than recomputed N times, N being your instance count — the result of an expensive downstream aggregation, a session or user-profile lookup, anything where a network round trip to Redis is still dramatically cheaper than recomputing from source. The architecture breaks in a specific, predictable way if the levels are inverted: putting genuinely large or rarely-reused data in L1 wastes memory per instance for a hit rate that doesn't justify it, and multiplies that waste by instance count with no sharing benefit at all; putting narrowly-hot, latency-critical data only in L2 pays a network round trip on every single access for data that could have been served from local memory, which is precisely the latency HybridCache's L1 tier exists to avoid. A well-tuned system typically shows a large fraction of requests satisfied from L1 alone, a smaller fraction falling through to L2, and only genuine misses reaching the source — if L2 hit rate is unexpectedly high relative to L1, that's usually a sign L1's TTL is too short or its key set too narrow for the actual access pattern.
What interviewers look for: a working set argument for what belongs in each tier (not "put everything in both"), and the specific failure modes of getting it backwards rather than a vague "it would be slower."
- Follow-up questions: how would you decide, empirically, whether a given piece of data belongs in L1 at all? What would you monitor to catch an L1/L2 sizing mistake in production before it's a customer-visible problem?
Q7 How do you reason about cache consistency, including read-after-write?#
Short answer: Decide explicitly, per piece of data, whether staleness is acceptable and for how long — most cached data can tolerate eventual consistency measured in seconds, some cannot tolerate any staleness at all for the specific user who just wrote it, and conflating the two leads either to over-caching data that needs strong consistency or under-caching data that didn't need it.
The read-after-write case is the sharpest version of this problem: a user updates their profile, the write lands in the source of truth and invalidates the cache, but a read that was already in flight — or arrives in the small window before invalidation propagates — can still return the old value, and to the user, "I just saved this and it still shows the old value" reads as a bug, not as expected eventual consistency. Common mitigations, roughly in order of how strong a guarantee they provide: route the specific user's immediate follow-up reads to the source of truth for a short window after their own write (bypass cache for "your own recent writes" specifically, since most users only notice staleness in their own data); version or timestamp cache entries and have the read path compare against a known-fresh marker before trusting the cached value; or, for data where staleness genuinely cannot be tolerated even briefly, don't cache it at all and accept the latency cost as the price of correctness. For data shared across users rather than tied to one writer's own follow-up read, eventual consistency with a bounded TTL is usually the right and sufficient answer — the cost of building read-your-own-writes guarantees everywhere is real, and most data doesn't need it. The architect-level judgment is knowing which category a given piece of data falls into, and saying so explicitly in the design rather than letting it default silently to whatever the caching library happens to do.
What interviewers look for: the specific read-after-write scenario named and solved concretely, and a clear statement that consistency requirements vary per data type rather than one blanket policy applied everywhere.
- Follow-up questions: how would you test for a read-after-write consistency bug before it reaches production? What's the cost, in complexity and latency, of guaranteeing read-your-own-writes everywhere versus only where it's actually needed?
Q8 What makes a good cache key, and what goes wrong with bad cache key design at scale?#
Short answer: A good cache key uniquely and deterministically encodes everything that affects the cached value — including things easy to forget, like locale, tenant, API version or feature-flag state — so that two logically different results can never collide under the same key, and two requests for the same logical thing always produce the same key.
The most common real-world bug is an under-specified key: caching a search result keyed only by query text while ignoring the caller's tenant, locale, or permission level means one tenant's cached result can leak into another tenant's response, or a paginated, sorted result gets served under a key that doesn't encode the page or sort order, silently returning page one's data for every page. The fix is deliberate, explicit key construction — typically a structured, delimited string built directly from every input that affects the output, such as tenant:{tenantId}:search:{normalizedQuery}:page:{page}:sort:{sortKey} — rather than an implicit key derived from "whatever object happens to be in scope." Keys should also be built directly inline at the call site rather than precomputed and stored, both for clarity and because some caching APIs specifically recommend inline interpolated strings so future optimizations can avoid allocating an intermediate string at all. At scale, two more practical constraints show up: out-of-process caches often enforce a maximum key length — HybridCache's default is 1024 characters, for instance — so a key built from unbounded input (an entire free-text query, a long list of IDs) needs truncation or hashing before it hits that ceiling; and some backends normalize keys in ways your application doesn't expect, such as case-insensitive matching, which can cause two keys your code treats as distinct to collide in the underlying store — a subtle, hard-to-reproduce correctness bug rather than a performance one.
What interviewers look for: naming the under-specified-key bug specifically (it's the single most common real-world cache correctness bug), and awareness of concrete constraints like key length limits and backend-specific normalization, not just "make the key unique" in the abstract.
- Common mistakes: keying by an object's default
ToString()or hash code, which is neither stable across process restarts nor guaranteed to encode every field that actually varies the result. - Follow-up questions: how would you cache a result that depends on a large, variable list of IDs without building an unbounded key? How would you safely evolve a key scheme without invalidating or colliding with every existing entry on deploy?
Q9 Compare Redis deployment topologies and their failure modes. How do you choose for a given availability requirement?#
Short answer: A standalone Redis instance is a single point of failure with the simplest operational model; primary-replica replication adds read scaling and a failover target but needs an external mechanism to actually detect failure and promote a replica; Sentinel adds that automatic monitoring and failover on top of a primary-replica pair; and Redis Cluster adds native sharding across multiple primary nodes, so it scales write throughput and capacity, not just reads, at the cost of clients needing to be cluster-aware.
A standalone instance is fine for a pure-optimization cache where a restart or brief unavailability is acceptable and simply results in more cache misses, never data loss of anything that matters — the source of truth is still intact. Primary-replica replication adds one or more read replicas that asynchronously receive the primary's writes; this improves read capacity and gives you a warm standby, but replication is asynchronous by default, so a primary failure can lose the last few writes that hadn't replicated yet, and without an external failover mechanism, promoting a replica to primary is a manual, error-prone operation during an incident. Sentinel processes monitor the primary and replicas, reach quorum on whether the primary is actually down (as opposed to a network partition making it look down to one observer), and automate promotion — closing the manual-failover gap, at the cost of running and correctly configuring the Sentinel processes themselves. Redis Cluster shards data across multiple primary nodes by hash slot, each optionally with its own replicas, giving you horizontal scaling of both capacity and write throughput plus built-in failover per shard, but client libraries must understand cluster redirection, and certain multi-key operations are restricted to keys that hash to the same slot unless you deliberately design keys to co-locate related data. Managed offerings such as Azure Cache for Redis package these topologies behind service tiers, trading configuration control for operational simplicity — the architectural decision is the same regardless of who operates it: how much data loss on primary failure is acceptable, how much manual intervention during an incident is acceptable, and whether a single primary's throughput ceiling is actually a constraint for your workload.
What interviewers look for: understanding that these topologies solve different problems (availability via Sentinel-managed failover versus horizontal scale via Cluster sharding) rather than treating them as a simple ladder from "basic" to "advanced," and the judgment to map a topology to an actual availability and scale requirement instead of defaulting to the most complex option.
- Follow-up questions: what happens to in-flight requests during a Sentinel-driven failover from your application's point of view? Why can't a standard multi-key transaction span two different hash slots in a clustered deployment?
Q10 A cache outage takes down the whole system even though the cache was "just an optimization." How did that happen, and how do you architect against it?#
Short answer: It happened because the application's fallback path — "just read from the source of truth on a cache miss" — was never actually load-tested, and a source of truth sized for a low cache-miss rate collapses the moment 100% of traffic suddenly needs to hit it directly during a cache outage, which then often triggers a stampede on cache recovery as everything tries to repopulate at once.
This is one of the most common real production incidents in systems with a caching layer, and it happens in a predictable sequence: the cache (or the network path to it) becomes unavailable; every request that would have hit cache now falls through to the database or downstream service; that backend, provisioned assuming a high cache hit rate, is suddenly serving its full, un-cached load and saturates connections, CPU or both; requests start timing out, including, often, the health checks that would otherwise trigger useful autoscaling or failover; and when the cache does come back, a stampede of simultaneous first-requests-after-recovery can re-trigger the same overload before the cache has had a chance to warm up. Architecting against this means treating "the cache is down" as a real failure mode to design for, not an edge case to ignore: put a circuit breaker or bulkhead around the fallback path to the source of truth so a cache outage can't turn into an unbounded flood of direct requests, deliberately fail closed (reject or degrade some requests) rather than fail open (let everything through to an overloaded backend) once that breaker trips, add jitter to cache TTLs so a large batch of entries doesn't expire in the same instant and compound an outage into a stampede, and size the source of truth — or explicitly decide you won't — for a worst-case cache-miss rate, not the steady-state one. A HybridCache-based design already helps here, since the in-process L1 tier keeps serving already-cached values even while the distributed L2 tier is unreachable, buying time for the circuit breaker and recovery path to do their job.
What interviewers look for: treating this as a fundamentally architectural question rather than an operational footnote, with concrete mechanisms (circuit breakers, fail-closed behavior, TTL jitter, capacity planning for the miss path) rather than "we'd just fix the cache faster next time."
- Follow-up questions: how would you load-test specifically for a cache-outage scenario before it happens in production? Where does a circuit breaker in front of the source of truth belong architecturally — in the caching layer itself, or in the client calling it?
See Resilience Patterns Interview Questions for more on circuit breakers and fallback design generally.
Quick-Fire Round#
| Question | Answer |
|---|---|
| Which pattern guarantees the cache and source of truth never diverge on write? | Write-through. |
| Which pattern risks losing an acknowledged write on a crash? | Write-behind (write-back). |
What problem does request coalescing in GetOrCreateAsync solve? | Cache stampedes on a hot key's expiry. |
Default MaximumPayloadBytes in HybridCache? | 1 MB. |
Default MaximumKeyLength in HybridCache? | 1024 characters. |
| Which HybridCache option controls the in-process (L1) TTL specifically? | LocalCacheExpiration. |
| Does HybridCache's stampede protection span multiple server instances? | No — it coordinates callers within one instance; L2 still matters across instances. |
| What automates Redis primary failover on top of plain replication? | Sentinel. |
| What does Redis Cluster add over Sentinel-managed replication? | Native sharding across multiple primary nodes. |
How to Prepare#
- Be able to state, precisely, the failure mode each caching pattern introduces — not just its latency characteristic.
- Practice designing a two-level TTL scheme and explaining why L1's TTL, not L2's, bounds worst-case staleness across instances.
- Know HybridCache's actual API surface (
GetOrCreateAsyncoverloads,HybridCacheEntryOptions, tag invalidation) well enough to write it from memory. - Prepare a cache-outage story, real or designed on the spot, that shows you've thought about the fallback path as a load-bearing part of the system, not an afterthought.
- Rehearse the Redis topology comparison as "which problem does each solve" (failover vs. scale) rather than a memorized feature list.