Distributed Caching in ASP.NET Core With IDistributedCache
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.
What Is It?
The Simple Explanation
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.
The Technical Definition
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.
IMemoryCache
- Lives inside one app instance's process memory
- Every instance has its own, separate cache
- Extremely fast (no network hop)
- Cache warm-up must happen independently, per instance
IDistributedCache
- Lives in a separate, shared service (e.g. Redis)
- Every instance reads and writes the same cache
- Slightly slower (a network round-trip), still very fast
- Cache warm-up happens once — every instance benefits immediately
Why Does It Exist?
The Problem — IMemoryCache Genuinely Cannot Do This
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:
- Inconsistent reads — the same request, hitting different instances, can get different cached answers, because one instance's cache reflects a write the others don't know about.
- Redundant warm-up — the very first request for a given piece of data on each instance is a cache miss, even if every other instance already has it cached. Ten instances means the expensive underlying work potentially runs ten separate times, once per instance, instead of once total.
The Solution — One Shared Cache, Reached Over the Network
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.
Big Picture
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
How It Works
byte[]? cached = await cache.GetAsync(cacheKey);
IDistributedCachestores raw bytes — you're responsible for serializing (usually to JSON) whatever object you're caching, and deserializing it back on the way out.
- If
cachedisnull, 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;
- Write the fresh result into the shared cache, then return it — so the very next request, from any instance, gets a cache hit.
- The cache sits "aside" the real data source, not in front of it automatically — your application code explicitly checks it, and explicitly populates it on a miss. This is the standard, most common caching pattern in distributed systems.
Simple Example
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.
Real-World Example
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.
Analogy
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.
Under the Hood
- Your code calls
GetAsync/SetAsyncon the abstraction. The Redis-specific NuGet package (Microsoft.Extensions.Caching.StackExchangeRedis) provides the concrete implementation wired up behind that interface at startup.
- Unlike
IMemoryCache's in-process dictionary lookup, everyGetAsync/SetAsynccall 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.
DistributedCacheEntryOptionsgets 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.
Common Confusion
"Just switch from IMemoryCache to IDistributedCache and everything works the same" — mostly, but not free
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.
"Distributed caching solves invalidation" — it doesn't, it just changes where it happens
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.
Common Mistakes
Mistake 1 — Assuming IMemoryCache is "good enough" once you scale out
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.
Mistake 2 — Forgetting the cache-aside pattern's invalidation step
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.
Mistake 3 — Treating the distributed cache as durable storage
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.
When Should I Use It?
- Use
IDistributedCachethe moment your app runs as more than one instance and cached data needs to be consistent across those instances. - Prefer it for data shared across users or sessions in a load-balanced deployment — catalog data, configuration, computed aggregates.
- Stick with plain
IMemoryCachefor 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). - Either way, the underlying hard problem doesn't change: only cache what's read far more than it's written, and always have a real invalidation plan.
Mental Model
IDistributedCache = a shared whiteboard, one for everyone
Redis = the room where the whiteboard lives
Cache-aside = check the board → miss? → get the real answer → write it on the board → answer
Remember: more than one instance means more than one memory. If the data needs to agree across instances, it can't live only in any one instance's memory.
Key Takeaway
IMemoryCacheis 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.IDistributedCacheis ASP.NET Core's abstraction over a shared cache that every instance reads from and writes to together.- Redis is the most common real backing store — a fast, network-accessible, in-memory key-value store built for exactly this role.
- The cache-aside pattern is the standard approach: check the cache → on a miss, query the real source → populate the cache → return the result — so future requests, from any instance, hit the shared cache.
- Distributed caching doesn't eliminate the invalidation problem from the previous lesson — it just makes it a shared, cross-instance concern instead of a per-instance one.
Check Your Understanding
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?
Show answer
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?
Show answer
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?
Show answer
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?
Show answer
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.