Three servers, three memories, three different answers to the same question.
The previous lesson's caching worked perfectly — in testing, on your one laptop, running one instance of the app. Then you deploy to production behind a load balancer, running three instances for reliability and capacity. A user updates their profile picture. The request happens to land on instance A, which updates the database and caches the new picture URL in its own IMemoryCache. The user refreshes the page. This request lands on instance B — which has never heard of that update. Its own, completely separate IMemoryCache still has the old picture cached, or nothing cached at all. The user sees their old photo, or a flash of inconsistent behavior, depending on which instance happens to answer each request.
This isn't a bug in your caching code. It's the fundamental nature of IMemoryCache: it lives inside one process. The moment you have more than one process, you have more than one cache, and they don't talk to each other.
In this lesson, you'll learn exactly why in-process caching breaks down at multiple instances, what IDistributedCache is, why Redis is the typical backing store behind it, and the cache-aside pattern that ties it all together.
Distributed caching means moving the cache out of your app's process and into a separate, shared service that every instance of your app connects to over the network. Instead of each instance keeping its own private notebook, every instance now writes to and reads from the same shared notebook.
IDistributedCache is ASP.NET Core's abstraction over a shared, out-of-process cache — an interface with the same basic shape as IMemoryCache (GetAsync, SetAsync, RemoveAsync), but backed by an external store reachable over the network rather than by local process memory. Because it's an abstraction, your application code doesn't need to know or care which specific technology is behind it — you write against IDistributedCache once, and the concrete backing store is a matter of configuration.
This isn't a matter of configuring IMemoryCache differently or being more careful — it's a hard architectural limit. Each instance of your app is a separate operating system process, with its own separate block of memory. IMemoryCache is, under the hood, essentially a dictionary sitting inside that memory. There is no built-in channel for instance A's dictionary to notify instance B's dictionary that an entry changed, because they don't share any memory at all — they're on entirely different machines (or at minimum, entirely different processes), often behind a load balancer that routes each incoming request to whichever instance it decides to, with no guarantee two requests from the same user even land on the same one.
This produces two distinct, real problems in a multi-instance deployment:
If the problem is "every instance has its own cache," the fix is structural: stop keeping the cache inside each instance's own memory, and instead put it somewhere all instances can reach — a separate service, reachable over the network, that every instance of your app talks to identically. Now a write from instance A is immediately visible to instances B and C on their very next read, because there's only one cache, not three.
WITHOUT DISTRIBUTED CACHING — 3 ISLANDS
Instance A Instance B Instance C
┌──────────┐ ┌──────────┐ ┌──────────┐
│IMemoryCache│ │IMemoryCache│ │IMemoryCache│
│ (empty) │ │ {pic: X} │ │ (empty) │
└──────────┘ └──────────┘ └──────────┘
↑ ↑ ↑
Request 1 Request 2 (wrote X) Request 3
(different answers, depending purely on which instance answers)
WITH DISTRIBUTED CACHING — 1 SHARED CACHE
Instance A Instance B Instance C
\ | /
\ | /
╲ ↓ ╱
┌─────────────────────────┐
│ Shared Cache (Redis) │
│ {pic: X} │
└─────────────────────────┘
Every instance reads and writes the SAME data — consistent, no redundant warm-up
byte[]? cached = await cache.GetAsync(cacheKey);
IDistributedCache stores raw bytes — you're responsible for serializing (usually to JSON) whatever object you're caching, and deserializing it back on the way out.cached is null, query the database (or call the slow API, or run the computation) exactly as if no cache existed at all.await cache.SetAsync(cacheKey, serialized, options);
return result;
Registering Redis as the backing store for IDistributedCache is almost entirely configuration — your application code depends only on the abstraction:
// Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "MyApp:";
});
And a cache-aside implementation, using IDistributedCache exactly as designed:
using System.Text.Json;
public class ProductService(AppDbContext db, IDistributedCache cache)
{
public async Task<Product?> GetProductAsync(int productId)
{
var cacheKey = $"product:{productId}";
// 1. Check the shared cache
var cachedBytes = await cache.GetAsync(cacheKey);
if (cachedBytes is not null)
{
return JsonSerializer.Deserialize<Product>(cachedBytes);
}
// 2. Miss — go to the real source
var product = await db.Products.FindAsync(productId);
if (product is null) return null;
// 3. Populate the cache before returning
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
};
await cache.SetAsync(cacheKey, JsonSerializer.SerializeToUtf8Bytes(product), options);
return product;
}
}
Notice this reads almost identically to the previous lesson's IMemoryCache example — same shape, same pattern. The difference that matters is entirely underneath: this cache entry is now visible to every instance of the app that shares the same Redis connection, not just the one that happened to write it.
Consider a shopping cart summary shown on every page of an e-commerce site — item count and total, computed from cart line items. Behind a load balancer running four instances, a user's requests can land on any of them as they browse. Without a shared cache, whichever instance last computed the total for this user is the only one that has it; the other three would each redundantly recompute it on their own first encounter with that user's session, and none of them would see a cart update made via a different instance without recomputing.
With IDistributedCache backed by Redis, one instance computes the cart summary once, caches it under a key like $"cart-summary:{userId}", and the moment the user adds an item — regardless of which instance handles that write — the code explicitly invalidates (removes) that same cache key. Every instance's very next read for that user is a clean cache miss that recomputes the correct, current total; every read before the next write is a fast, consistent hit, identical no matter which instance answers it.
IDistributedCache because it's an extremely fast, in-memory, network-accessible key-value store purpose-built for exactly this job — high-throughput reads and writes of small values, with built-in expiration support, from many concurrent clients at once. This lesson isn't a Redis administration tutorial — the point to take away is conceptual: Redis is a separate, shared, fast piece of infrastructure that all your app instances point at, filling the role IMemoryCache filled for a single instance.
IMemoryCache is like every employee keeping their own private notebook of answers to common questions. Fast to check, but if one employee learns something new, the other employees' notebooks don't update — they're each working from their own, possibly outdated, private copy.
IDistributedCache is like the whole team sharing one whiteboard in the break room. Anyone who learns something writes it on the board; anyone who needs the answer checks the board first. It takes a few extra seconds to walk over and check (the network round-trip) compared to flipping open your own notebook — but everyone is reading the exact same, current information.
GetAsync/SetAsync on the abstraction. The Redis-specific NuGet package (Microsoft.Extensions.Caching.StackExchangeRedis) provides the concrete implementation wired up behind that interface at startup.IMemoryCache's in-process dictionary lookup, every GetAsync/SetAsync call against a distributed cache goes out over the network to the Redis server and back — still very fast (often sub-millisecond to a few milliseconds on a well-placed Redis instance), but not free the way a local dictionary lookup is.DistributedCacheEntryOptions gets translated into Redis's own native expiration mechanism — the cache server, not your app process, is the one that actually enforces the TTL and evicts the entry when it lapses.The API shapes are deliberately similar, and for many use cases swapping the implementation genuinely is close to a drop-in change. But it isn't free: IDistributedCache requires you to serialize and deserialize your data yourself (it only stores raw byte[]), and every operation now costs a real network round-trip instead of an in-process lookup. For a value read millions of times a second inside a single request's processing, that added latency and serialization cost genuinely matters — it's why some systems use both: a fast local IMemoryCache as a first-level cache, backed by a shared distributed cache as the second level.
Moving to a shared cache doesn't make the previous lesson's hard problem — knowing when a cached value is stale — go away. It actually adds a wrinkle: now every instance needs to agree on when and how to invalidate a shared entry, since a write from any one of them affects what every other instance sees next. The cache-aside pattern's explicit invalidation step is exactly how you handle this — but you still have to write it correctly.
Keeping IMemoryCache in a multi-instance deployment and hoping the inconsistency "won't matter much" — it will surface, unpredictably, exactly when traffic is highest and load-balancing spreads requests widest.
Once you run more than one instance, treat shared, potentially-inconsistent state as a real design question, not an edge case — reach for IDistributedCache for anything that must be consistent across instances.
Populating the cache on read but never removing or updating the entry when the underlying data changes on write — the shared cache becomes a single, consistently wrong answer served to every instance, which is arguably worse than the per-instance inconsistency it replaced.
Every code path that writes to the real data source should also invalidate (or update) the corresponding cache entry, as part of the same operation.
Relying on Redis (or any distributed cache) as the only place a piece of data lives — caches are typically configured for performance, not durability, and can lose data on restart or eviction under memory pressure.
The real source of truth stays the database (or other durable store); the cache-aside pattern's whole design assumes the cache can be safely emptied at any time and rebuilt from the real source.
IDistributedCache the moment your app runs as more than one instance and cached data needs to be consistent across those instances.IMemoryCache for a genuinely single-instance app, or for extremely hot, per-request-cheap lookups where a network round-trip would erase the benefit entirely (sometimes layered as a fast local cache in front of the distributed one).IMemoryCache is per-instance — in a multi-instance deployment, each instance has its own separate, invisible-to-the-others cache, causing inconsistent reads and redundant warm-up.IDistributedCache is ASP.NET Core's abstraction over a shared cache that every instance reads from and writes to together.You've seen why IMemoryCache breaks down across multiple instances, and how IDistributedCache and cache-aside fix it. Let's confirm the reasoning.
1. A load-balanced app runs three instances, each using IMemoryCache. A write on instance A updates a cached value. What happens on instance B's very next read of that same key?
Correct: B
Why B is correct: IMemoryCache is scoped to the process it runs in. Instance A and instance B are separate processes with separate memory — there's no built-in channel for one instance's cache writes to reach another's. This is the exact motivating problem for IDistributedCache.
Why A is incorrect: This is precisely the false assumption the lesson corrects — IMemoryCache has no cross-instance synchronization mechanism at all.
Why C is incorrect: A stale or missing cache entry doesn't throw — it's silently inconsistent, which is arguably worse because it's easy to miss.
Why D is incorrect: Load balancers route based on their own configured strategy (round robin, least connections, etc.) — they have no awareness of application-level cache state.
Reinforcement: Multiple instances mean multiple independent copies of any in-process cache — consistency across instances requires moving the cache out of any single process.
2. Which best describes the cache-aside pattern?
Correct: B
Why B is correct: This is the exact, standard definition of cache-aside — the application explicitly manages the check-miss-populate flow around the real data source; the cache sits "aside" it, not automatically in front of it.
Why A is incorrect: Nothing about IDistributedCache automatically intercepts database calls — your code explicitly checks the cache as a deliberate step.
Why C is incorrect: There's no automatic mirroring between a database and a distributed cache unless you build that synchronization yourself — cache-aside populates the cache from reads, not from database writes directly.
Why D is incorrect: Cache-aside checks happen on every read, not on a fixed schedule.
Reinforcement: Cache-aside is an application-level pattern: check, miss, fetch, populate, return — explicit at every step.
3. Why is Redis commonly chosen as the backing store behind IDistributedCache, rather than, say, just querying the primary application database directly on every "cache" lookup?
Correct: B
Why B is correct: Redis is optimized for exactly the caching workload — fast in-memory key-value access at high concurrency, with native TTL/expiration support — which is a different job than a relational database optimized for durable, transactional, relationally-structured storage.
Why A is incorrect: IDistributedCache is an abstraction; other backing stores exist (e.g. SQL Server-based distributed cache), Redis is just the most common choice, not the only possible one.
Why C is incorrect: Redis stores whatever your application code explicitly serializes and writes to it — it doesn't automatically know about or convert database queries.
Why D is incorrect: Staleness is still entirely possible with Redis — the invalidation strategies from the caching lesson still fully apply; Redis just provides fast, shared storage, not a staleness guarantee.
Reinforcement: Redis's role is providing fast, shared, expiring key-value storage — not solving the invalidation problem for you.
4. A team migrates from IMemoryCache to IDistributedCache backed by Redis but keeps calling Get/Set with plain .NET objects, expecting it to work unchanged. What's the most likely issue they'll hit?
Correct: B
Why B is correct: Unlike IMemoryCache, which can store arbitrary .NET objects directly by reference, IDistributedCache's Get/Set operate on byte[] — data has to cross a real network boundary to an external store, so it must be serialized going in and deserialized coming out. This is a genuine adjustment, not a drop-in swap.
Why A is incorrect: This is exactly the wrong assumption the lesson calls out — the API shapes are similar, but the storage model (bytes vs. objects) and cost (network round-trip vs. in-process lookup) are meaningfully different.
Why C is incorrect: Redis handles values well beyond a single integer — this isn't a real constraint relevant here.
Why D is incorrect: IDistributedCache is registered and injected via standard DI just like IMemoryCache — constructor injection works the same way.
Reinforcement: Switching cache implementations changes real behavior underneath a similar-looking API — serialization and network cost are the concrete differences that matter in practice.
You now understand why in-process caching breaks down at scale, and how IDistributedCache with the cache-aside pattern keeps every instance of your app looking at the same, consistent data.
dotnetmadeeasy.com — Learn C# and .NET, the right way.