👋Hi, I'm Waqas — a Software Architect and Technical Consultant specializing in .NET, Azure, microservices, and API-first system design..
I help companies build reliable, maintainable, and high-performance backend platforms that scale.
Caching Strategies: Redis, In-Memory, and Distributed in .NET
In-memory, distributed, and response caching in .NET. Redis and cache invalidation.
May 20, 2024 · Waqas Ahmad
Read the article
Introduction
This guidance is relevant when the topic of this article applies to your system or design choices; it breaks down when constraints or context differ. I’ve applied it in real projects and refined the takeaways over time (as of 2026).
Applications that hit the database or external services on every request suffer from unnecessary latency and backend load, especially at scale. This article explains in-process caching with IMemoryCache, distributed caching with Redis and IDistributedCache, response caching, and patterns such as cache-aside and write-through, plus invalidation and failure handling. Choosing the right strategy and designing keys and invalidation correctly matters for architects and tech leads who need predictable performance and resilience without stale data or cache-induced outages.
For a deeper overview of this topic, explore the full .NET Architecture guide.
System scale: Single instance to many; read-heavy or mixed read/write workloads. Applies when you want to reduce database or backend load and improve latency.
Team size: One developer to a platform team; someone must own cache topology, invalidation, and failure behaviour. Works when dev and ops agree on what can be cached and how to invalidate.
Time / budget pressure: Fits when you have time to design keys, TTLs, and invalidation; breaks down when data is always fresh or consistency is strict and you can’t tolerate staleness.
Technical constraints: .NET (IMemoryCache, IDistributedCache); Redis or compatible store for distributed; optionally Azure Cache for Redis. Assumes you can run or consume a cache service.
Non-goals: This article does not optimise for application-level only (no cache), or for CDN/edge-only; it focuses on in-process and distributed caching in .NET with Redis.
Types of caching
Type
Where
Use case
In-memory (IMemoryCache)
Single process
Single instance apps, non-critical data
Distributed (Redis)
Shared across instances
Multi-instance apps, shared state
Response caching
HTTP layer
Static or rarely-changing responses
Output caching (.NET 7+)
Server-side
Full page or endpoint caching
IMemoryCache: in-process caching
IMemoryCache stores data in the application’s memory. It is fast (no network), but the cache is lost on restart and not shared across instances.
When to use:
Single-instance applications
Data that can be regenerated
Short-lived caching (e.g. lookup data)
Setup:
// Program.cs
builder.Services.AddMemoryCache();
Usage:
publicclassProductService
{
privatereadonly IMemoryCache _cache;
privatereadonly IProductRepository _repository;
publicProductService(IMemoryCache cache, IProductRepository repository)
{
_cache = cache;
_repository = repository;
}
publicasync Task<Product?> GetProductAsync(string id)
{
var cacheKey = $"product:{id}";
if (_cache.TryGetValue(cacheKey, out Product? cached))
{
return cached;
}
var product = await _repository.GetByIdAsync(id);
if (product != null)
{
var options = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromMinutes(10))
.SetSlidingExpiration(TimeSpan.FromMinutes(2));
_cache.Set(cacheKey, product, options);
}
return product;
}
}
Expiration options:
Option
Description
AbsoluteExpiration
Fixed time when entry expires
AbsoluteExpirationRelativeToNow
Expires after X time from now
SlidingExpiration
Resets on each access; expires if not accessed
IDistributedCache: shared caching
IDistributedCache is an abstraction over distributed caches like Redis, SQL Server, or NCache. Data is shared across all application instances.
1. Use distributed cache for multi-instance. If you have more than one instance, use Redis. In-memory cache will be inconsistent.
2. Set appropriate TTLs. Short for frequently-changing data; longer for stable data. Balance freshness vs hit rate.
3. Use cache-aside by default. It is simple and works for most read-heavy scenarios.
4. Invalidate explicitly on writes. Do not rely only on TTL for critical data. Remove or update cache entries on write.
5. Handle cache failures gracefully. If Redis is down, fall back to database. Do not let cache failures break your app.
try
{
var cached = await _cache.GetStringAsync(key);
if (cached != null) return JsonSerializer.Deserialize<Order>(cached);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Cache read failed for {Key}", key);
}
// Fall back to databasereturnawait _repository.GetByIdAsync(id);
6. Monitor cache metrics. Track hit rate, miss rate, latency, and memory usage. Low hit rate means your caching strategy needs adjustment.
7. Use consistent key naming. Format: {entity}:{id} or {service}:{entity}:{id}. Makes debugging and invalidation easier.
8. Avoid large objects. Serialisation cost increases with size. Consider caching IDs and fetching details separately.
Common issues
Issue
Symptom
Fix
Stale data
Users see outdated info
Invalidate on write; reduce TTL
Cache stampede
DB hammered on expiry
Use locking or stale-while-revalidate
Memory pressure
Out of memory errors
Set size limits; use eviction (LRU)
Serialisation errors
Exceptions on read/write
Use consistent serialiser; handle nulls
Redis connection issues
Timeouts, failures
Use connection pooling; handle exceptions
Inconsistent keys
Partial invalidation
Use consistent key format; document patterns
Cache stampede prevention:
privatestaticreadonly SemaphoreSlim _lock = new(1, 1);
publicasync Task<Order?> GetOrderWithLockAsync(string id)
{
var cacheKey = $"order:{id}";
var cached = await _cache.GetStringAsync(cacheKey);
if (cached != null) return JsonSerializer.Deserialize<Order>(cached);
await _lock.WaitAsync();
try
{
// Double-check after acquiring lock
cached = await _cache.GetStringAsync(cacheKey);
if (cached != null) return JsonSerializer.Deserialize<Order>(cached);
var order = await _repository.GetByIdAsync(id);
if (order != null)
{
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(order),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });
}
return order;
}
finally
{
_lock.Release();
}
}
You can also explore more patterns in the .NET Architecture resource page.
Summary
Caching in .NET reduces latency and backend load when you use IMemoryCache for single-instance apps and Redis (IDistributedCache) for multi-instance or shared state; cache-aside with explicit invalidation on write is the default pattern. Getting invalidation and failure behaviour wrong leads to stale data or outages—designing key schema and TTLs up front and handling Redis unavailability keeps systems reliable. Review your read-heavy paths and multi-instance deployment next; decide where a cache belongs and how you will invalidate it before adding one.
Position & Rationale
I use IMemoryCache when the app runs as a single instance or when the data is process-local and doesn’t need to be shared (e.g. per-request or per-instance lookups). I use Redis (IDistributedCache) when there are multiple instances or when cache must survive restarts and be consistent across replicas. I avoid caching when data is always fresh or when invalidation is unclear—stale cache is worse than no cache. I prefer cache-aside over write-through for most read-heavy cases because it’s simpler and invalidation on write is explicit; I use write-through only when I need the cache to always reflect the source of truth and can afford the write path complexity. I always design cache keys and TTLs up front; ad-hoc keys lead to unbounded growth and hard-to-debug invalidation.
Trade-Offs & Failure Modes
In-memory sacrifices consistency across instances and persistence across restarts; you gain simplicity and no network dependency. Distributed (Redis) adds network latency and a dependency; you gain shared state and persistence. Caching in general sacrifices freshness for speed; wrong TTL or missed invalidation causes stale data. Failure modes: cache stampede on expiry (many requests hitting the backend at once); Redis down and no fallback (app should degrade to backend); over-caching and never invalidating (stale reads); caching non-idempotent or user-specific data in a shared key (security or correctness bugs).
What Most Guides Miss
Most guides show “how to use IMemoryCache and Redis” but don’t stress invalidation as the hard part. Deciding when to remove or update cache (on write, on event, on TTL) and doing it correctly across instances (pub/sub, or accept per-instance delay) is where things go wrong. Another gap: failure behaviour—what happens when Redis is unavailable? You need a fallback (e.g. direct to database) and circuit-breaker or timeout so one slow Redis doesn’t take down the app. Key design is underplayed: keys that are too broad cause over-invalidation; keys that are too fine cause low hit rate and memory growth.
Decision Framework
If single instance and data can be process-local → IMemoryCache; keep it simple.
If multiple instances or cache must survive restarts → Redis (IDistributedCache); use a shared connection.
If read-heavy and writes are infrequent → Cache-aside; invalidate (remove or update) on write.
If you need cache and source always in sync on write → Write-through; accept write latency.
For production → Define key schema and TTLs; handle Redis failure (fallback, timeouts); monitor hit rate and evictions.
Key Takeaways
IMemoryCache for single instance; Redis for multi-instance or shared, persistent cache.
Cache-aside with invalidation on write is the default; write-through only when you need strict consistency.
Design keys and TTLs up front; avoid ad-hoc keys and unbounded growth.
Handle Redis failure: fallback to backend, timeouts, and monitoring so the app degrades gracefully.
Invalidation is the hard part—get it right or accept staleness explicitly.
When I Would Use This Again — and When I Wouldn’t
I’d use IMemoryCache again for single-instance apps and for data that doesn’t need to be shared. I’d use Redis again for multi-instance .NET apps and when cache must survive restarts. I wouldn’t add caching when the data is always fresh or when I can’t define a clear invalidation strategy—stale cache leads to bugs and confusion. I also wouldn’t cache without a fallback when Redis is down; the app must still work (slower) without the cache.
Frequently Asked Questions
Frequently Asked Questions
What is cache-aside?
Cache-aside (lazy loading): application checks cache first. On miss, loads from database and stores in cache. Most common pattern.
What is distributed cache?
Distributed cache is shared across app instances (e.g. Redis). Use when you have multiple instances or need consistent cache.
When should I use Redis vs IMemoryCache?
Use IMemoryCache for single instance. Use Redis (IDistributedCache) for multi-instance deployments or when cache must survive restarts.
How do I invalidate cache on data change?
Remove or update the cache entry on write: await _cache.RemoveAsync(key). For multi-instance, use pub/sub or messaging.
What is cache stampede?
When many requests miss at the same time (e.g. on expiry) and all hit the database. Prevent with locking or stale-while-revalidate.
What TTL should I use?
Depends on data freshness requirements. Minutes for frequently-changing data; hours for stable data. Monitor and adjust.
How do I handle Redis failures?
Wrap cache calls in try-catch. Fall back to database if cache is unavailable. Log warnings for monitoring.
What is write-through caching?
Update both cache and database on write. Ensures cache is always fresh but adds latency to writes.
What is response caching?
Caching HTTP responses at the server or CDN level. Use [ResponseCache] attribute in ASP.NET Core.
How do I monitor cache performance?
Track hit rate, miss rate, latency, and evictions. Use Redis INFO command or Azure Monitor for Azure Cache for Redis.
What serialisation format should I use?
JSON is simple and debuggable. For performance-critical paths, consider MessagePack or protobuf.
Can I cache null values?
Yes, to prevent repeated database lookups for non-existent keys. Use a sentinel value or separate “not found” cache.
How do I cache collections?
Cache the list of IDs, then cache individual items. Invalidate the list when items are added/removed.
What is Azure Cache for Redis?
Managed Redis on Azure with automatic scaling, high availability, and geo-replication. Common choice for .NET apps on Azure.
How do I warm the cache on startup?
Load frequently-accessed data at application startup. Use IHostedService or startup hooks to pre-populate cache.
Related Guides & Resources
Explore the matching guide, related services, and more articles.