Waqas Ahmad — Software Architect & Technical Consultant - Available USA, Europe, Global

Waqas Ahmad — Software Architect & Technical Consultant

Specializing in

Distributed Systems

.NET ArchitectureCloud-Native ArchitectureAzure Cloud EngineeringAPI ArchitectureMicroservices ArchitectureEvent-Driven ArchitectureDatabase Design & Optimization

👋 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.

Experienced across engineering ecosystems shaped by Microsoft, the Cloud Native Computing Foundation, and the Apache Software Foundation.

Available for remote consulting (USA, Europe, Global) — flexible across EST, PST, GMT & CET.

services
Article

Caching Strategies: Redis, In-Memory, and Distributed in .NET

In-memory, distributed, and response caching in .NET. Redis and cache invalidation.

services
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.

Decision Context

  • 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:

public class ProductService
{
    private readonly IMemoryCache _cache;
    private readonly IProductRepository _repository;

    public ProductService(IMemoryCache cache, IProductRepository repository)
    {
        _cache = cache;
        _repository = repository;
    }

    public async 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.

When to use:

  • Multi-instance deployments (load-balanced)
  • Data that must be consistent across instances
  • Session state, shopping carts, user preferences

Setup with Redis:

// Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "myapp:";
});

Usage:

public class OrderService
{
    private readonly IDistributedCache _cache;
    private readonly IOrderRepository _repository;

    public OrderService(IDistributedCache cache, IOrderRepository repository)
    {
        _cache = cache;
        _repository = repository;
    }

    public async Task<Order?> GetOrderAsync(string id)
    {
        var cacheKey = $"order:{id}";
        
        var cached = await _cache.GetStringAsync(cacheKey);
        if (cached != null)
        {
            return JsonSerializer.Deserialize<Order>(cached);
        }

        var order = await _repository.GetByIdAsync(id);
        
        if (order != null)
        {
            var options = new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
            };
            
            await _cache.SetStringAsync(
                cacheKey, 
                JsonSerializer.Serialize(order), 
                options);
        }

        return order;
    }

    public async Task InvalidateOrderAsync(string id)
    {
        await _cache.RemoveAsync($"order:{id}");
    }
}

Redis: the go-to distributed cache

Redis is an in-memory data store that supports strings, hashes, lists, sets, and more. It is the most common distributed cache for .NET applications.

Azure Cache for Redis is the managed Redis offering on Azure:

  • Scales automatically
  • High availability with replicas
  • Geo-replication for global apps
  • Private endpoints for security

Connection string format:

myredis.redis.cache.windows.net:6380,password=...,ssl=True,abortConnect=False

Redis data types for caching:

Type Use case
String Simple key-value (most common)
Hash Object with multiple fields
List Queues, recent items
Set Unique collections, tags
Sorted Set Leaderboards, rankings

Caching patterns

Cache-aside (lazy loading)

The application checks the cache first. On a miss, it loads from the database and populates the cache.

public async Task<T?> GetOrSetAsync<T>(string key, Func<Task<T?>> factory, TimeSpan ttl)
{
    var cached = await _cache.GetStringAsync(key);
    if (cached != null)
    {
        return JsonSerializer.Deserialize<T>(cached);
    }

    var data = await factory();
    if (data != null)
    {
        await _cache.SetStringAsync(
            key, 
            JsonSerializer.Serialize(data),
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ttl });
    }

    return data;
}

// Usage
var order = await GetOrSetAsync(
    $"order:{id}",
    () => _repository.GetByIdAsync(id),
    TimeSpan.FromMinutes(10));

Write-through

On write, update both the cache and the database together.

public async Task UpdateOrderAsync(Order order)
{
    // Update database
    await _repository.UpdateAsync(order);
    
    // Update cache
    await _cache.SetStringAsync(
        $"order:{order.Id}",
        JsonSerializer.Serialize(order),
        new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });
}

Write-behind (async)

Write to cache immediately; persist to database asynchronously. Use for high-write scenarios where slight delay is acceptable.

Cache invalidation

Invalidation ensures the cache reflects the current state of the data. Options:

Strategy Description When to use
Time-based (TTL) Entry expires after X time Data that can be slightly stale
Explicit invalidation Remove entry on update/delete Critical data, exact consistency
Event-based Pub/sub triggers invalidation Multi-instance, complex dependencies

Explicit invalidation:

public async Task DeleteOrderAsync(string id)
{
    await _repository.DeleteAsync(id);
    await _cache.RemoveAsync($"order:{id}");
}

Event-based with Redis pub/sub:

// Publisher (on update)
await _subscriber.PublishAsync("cache:invalidate", $"order:{id}");

// Subscriber (all instances)
_subscriber.Subscribe("cache:invalidate", (channel, message) =>
{
    _cache.Remove(message);
});

Response caching

Response caching caches HTTP responses. The client or a CDN can serve cached responses without hitting your server.

// Program.cs
builder.Services.AddResponseCaching();

var app = builder.Build();
app.UseResponseCaching();

// Controller
[HttpGet("{id}")]
[ResponseCache(Duration = 60, VaryByQueryKeys = new[] { "id" })]
public async Task<ActionResult<Product>> GetProduct(string id)
{
    return Ok(await _service.GetProductAsync(id));
}

Output caching (.NET 7+):

builder.Services.AddOutputCache();

app.UseOutputCache();

app.MapGet("/products/{id}", async (string id, IProductService service) =>
{
    return await service.GetProductAsync(id);
}).CacheOutput(policy => policy.Expire(TimeSpan.FromMinutes(5)));

Enterprise best practices

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 database
return await _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:

private static readonly SemaphoreSlim _lock = new(1, 1);

public async 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.

services
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.

services
Related Guides & Resources

services
Related services