Optimizing Application Performance with Caching Strategies in .NET

Why Caching Transforms Performance

Caching stores frequently accessed data in fast memory so you don't have to fetch it repeatedly from slower sources like databases or external APIs. A well-implemented cache can reduce response times from seconds to milliseconds and dramatically decrease load on your backend systems.

The right caching strategy depends on your application's access patterns, data freshness requirements, and scalability needs. In-memory caching works great for single-server applications, while distributed caching becomes essential when you scale horizontally across multiple instances.

You'll learn how to use IMemoryCache for in-memory caching, configure expiration policies that match your data characteristics, and understand when distributed caching makes sense for your architecture.

Getting Started with IMemoryCache

IMemoryCache provides fast in-memory caching within a single application instance. It's built into ASP.NET Core and integrates seamlessly with dependency injection. The cache automatically manages memory pressure and evicts entries when memory runs low.

You inject IMemoryCache into your services and use simple Get/Set operations to store and retrieve data. The cache stores items as objects, so you need to cast when retrieving them. For type safety, use the generic extension methods.

Here's how to configure and use IMemoryCache in a typical ASP.NET Core application:

Program.cs - Configuring MemoryCache
using Microsoft.Extensions.Caching.Memory;

var builder = WebApplication.CreateBuilder(args);

// Register memory cache
builder.Services.AddMemoryCache(options =>
{
    options.SizeLimit = 1024; // Optional: limit cache size
    options.CompactionPercentage = 0.25; // Remove 25% when limit reached
});

builder.Services.AddScoped<IProductService, ProductService>();

var app = builder.Build();
app.MapGet("/products/{id}", async (int id, IProductService service) =>
{
    var product = await service.GetProductAsync(id);
    return product;
});

app.Run();

public interface IProductService
{
    Task<Product> GetProductAsync(int id);
}

public class ProductService : IProductService
{
    private readonly IMemoryCache _cache;
    private readonly ILogger<ProductService> _logger;

    public ProductService(IMemoryCache cache, ILogger<ProductService> logger)
    {
        _cache = cache;
        _logger = logger;
    }

    public async Task<Product> GetProductAsync(int id)
    {
        string cacheKey = $"product_{id}";

        // Try to get from cache
        if (_cache.TryGetValue(cacheKey, out Product cachedProduct))
        {
            _logger.LogInformation("Cache hit for product {Id}", id);
            return cachedProduct;
        }

        // Cache miss - fetch from database
        _logger.LogInformation("Cache miss for product {Id}", id);
        var product = await FetchFromDatabaseAsync(id);

        // Store in cache with options
        var cacheOptions = new MemoryCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
            Size = 1 // For size-based eviction
        };

        _cache.Set(cacheKey, product, cacheOptions);
        return product;
    }

    private async Task<Product> FetchFromDatabaseAsync(int id)
    {
        await Task.Delay(100); // Simulate database call
        return new Product { Id = id, Name = $"Product {id}", Price = 99.99m };
    }
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
}

The TryGetValue pattern checks for cache existence and retrieves the value in one operation. This avoids race conditions and provides better performance than checking ContainsKey separately. When the cache misses, you fetch the data and store it with appropriate expiration settings.

Mastering Expiration Policies

Expiration policies determine when cached data becomes stale and gets removed. Absolute expiration removes entries after a fixed time, while sliding expiration extends the lifetime each time you access the entry. Choosing the right policy keeps your cache fresh without unnecessary reloading.

Absolute expiration works well for time-sensitive data like authentication tokens or daily reports. Sliding expiration fits frequently accessed data where recent access indicates future access. You can combine both policies for fine-grained control.

Here's how different expiration strategies work in practice:

CachingService.cs - Expiration strategies
using Microsoft.Extensions.Caching.Memory;

public class CachingService
{
    private readonly IMemoryCache _cache;

    public CachingService(IMemoryCache cache)
    {
        _cache = cache;
    }

    // Absolute expiration - expires at exact time
    public void CacheWithAbsoluteExpiration(string key, object value)
    {
        var options = new MemoryCacheEntryOptions
        {
            // Expires 10 minutes from now, regardless of access
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
        };

        _cache.Set(key, value, options);
    }

    // Sliding expiration - extends on each access
    public void CacheWithSlidingExpiration(string key, object value)
    {
        var options = new MemoryCacheEntryOptions
        {
            // Expires 5 minutes after last access
            SlidingExpiration = TimeSpan.FromMinutes(5)
        };

        _cache.Set(key, value, options);
    }

    // Combined expiration - slides but has maximum lifetime
    public void CacheWithCombinedExpiration(string key, object value)
    {
        var options = new MemoryCacheEntryOptions
        {
            // Slides on access, but expires after 30 minutes maximum
            SlidingExpiration = TimeSpan.FromMinutes(5),
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
        };

        _cache.Set(key, value, options);
    }

    // Priority-based eviction
    public void CacheWithPriority(string key, object value, bool isImportant)
    {
        var options = new MemoryCacheEntryOptions
        {
            Priority = isImportant ? CacheItemPriority.NeverRemove : CacheItemPriority.Low,
            SlidingExpiration = TimeSpan.FromMinutes(10)
        };

        _cache.Set(key, value, options);
    }

    // Callback on eviction
    public void CacheWithCallback(string key, object value)
    {
        var options = new MemoryCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5),
            PostEvictionCallbacks =
            {
                new PostEvictionCallbackRegistration
                {
                    EvictionCallback = (k, v, reason, state) =>
                    {
                        Console.WriteLine($"Cache entry '{k}' evicted. Reason: {reason}");
                    }
                }
            }
        };

        _cache.Set(key, value, options);
    }

    // GetOrCreate pattern - atomic cache-aside
    public async Task<Product> GetOrCreateProductAsync(int id)
    {
        return await _cache.GetOrCreateAsync(
            $"product_{id}",
            async entry =>
            {
                entry.SlidingExpiration = TimeSpan.FromMinutes(5);
                entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);

                // This only runs on cache miss
                return await FetchProductFromDatabaseAsync(id);
            });
    }

    private async Task<Product> FetchProductFromDatabaseAsync(int id)
    {
        await Task.Delay(100);
        return new Product { Id = id, Name = $"Product {id}", Price = 99.99m };
    }
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
}

The GetOrCreateAsync pattern simplifies cache-aside logic by handling the check-fetch-store cycle atomically. This prevents multiple threads from fetching the same data simultaneously. PostEvictionCallbacks let you log evictions or trigger refresh logic when entries expire.

Scaling with Distributed Caching

Distributed caching stores data in a shared cache server accessible by all application instances. This solves cache consistency problems when you scale horizontally and provides cache persistence across application restarts. Redis is the most popular distributed cache in the .NET ecosystem.

IDistributedCache provides a unified interface for distributed caching. You can swap implementations without changing application code. The API differs from IMemoryCache because it works with byte arrays instead of objects, reflecting the serialization required for network transport.

Here's how to set up and use distributed caching with Redis:

Program.cs - Distributed caching setup
using Microsoft.Extensions.Caching.Distributed;
using System.Text.Json;

var builder = WebApplication.CreateBuilder(args);

// Add Redis distributed cache
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "localhost:6379";
    options.InstanceName = "MyApp_";
});

builder.Services.AddScoped<ICatalogService, CatalogService>();

var app = builder.Build();
app.MapGet("/catalog", async (ICatalogService service) =>
{
    return await service.GetCatalogAsync();
});

app.Run();

public interface ICatalogService
{
    Task<List<Product>> GetCatalogAsync();
}

public class CatalogService : ICatalogService
{
    private readonly IDistributedCache _cache;
    private readonly ILogger<CatalogService> _logger;

    public CatalogService(IDistributedCache cache, ILogger<CatalogService> logger)
    {
        _cache = cache;
        _logger = logger;
    }

    public async Task<List<Product>> GetCatalogAsync()
    {
        const string cacheKey = "product_catalog";

        // Try to get from distributed cache
        var cachedData = await _cache.GetStringAsync(cacheKey);

        if (cachedData != null)
        {
            _logger.LogInformation("Distributed cache hit");
            return JsonSerializer.Deserialize<List<Product>>(cachedData);
        }

        // Cache miss - fetch data
        _logger.LogInformation("Distributed cache miss");
        var products = await FetchCatalogFromDatabaseAsync();

        // Serialize and store in cache
        var serialized = JsonSerializer.Serialize(products);

        var options = new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30),
            SlidingExpiration = TimeSpan.FromMinutes(10)
        };

        await _cache.SetStringAsync(cacheKey, serialized, options);
        return products;
    }

    private async Task<List<Product>> FetchCatalogFromDatabaseAsync()
    {
        await Task.Delay(200); // Simulate database query

        return new List<Product>
        {
            new Product { Id = 1, Name = "Laptop", Price = 999.99m },
            new Product { Id = 2, Name = "Mouse", Price = 29.99m },
            new Product { Id = 3, Name = "Keyboard", Price = 79.99m }
        };
    }
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
}

Distributed caching requires serialization, which adds CPU overhead compared to in-memory caching. Choose serialization formats carefully - JSON works well for most scenarios, but binary formats like MessagePack can be faster for large objects. Consider a two-tier cache with in-memory L1 and distributed L2 for optimal performance.

Handling Cache Invalidation

Cache invalidation is notoriously difficult because you need to ensure stale data doesn't persist after updates. Time-based expiration works for many scenarios, but sometimes you need explicit invalidation when data changes.

For in-memory caches, you can remove entries directly. For distributed caches across multiple servers, you need coordination through message buses or cache invalidation services. Tag-based invalidation lets you remove multiple related entries at once.

Here's a pattern for managing cache invalidation with updates:

ProductRepository.cs - Cache invalidation pattern
using Microsoft.Extensions.Caching.Memory;

public class ProductRepository
{
    private readonly IMemoryCache _cache;
    private readonly ILogger<ProductRepository> _logger;

    public ProductRepository(IMemoryCache cache, ILogger<ProductRepository> logger)
    {
        _cache = cache;
        _logger = logger;
    }

    public async Task<Product> GetProductAsync(int id)
    {
        string cacheKey = GetCacheKey(id);

        return await _cache.GetOrCreateAsync(cacheKey, async entry =>
        {
            entry.SlidingExpiration = TimeSpan.FromMinutes(10);
            return await FetchFromDatabaseAsync(id);
        });
    }

    public async Task UpdateProductAsync(Product product)
    {
        // Update database
        await SaveToDatabaseAsync(product);

        // Invalidate specific cache entry
        string cacheKey = GetCacheKey(product.Id);
        _cache.Remove(cacheKey);

        // Invalidate related caches
        _cache.Remove("product_list");
        _cache.Remove($"category_{product.CategoryId}_products");

        _logger.LogInformation("Invalidated cache for product {Id}", product.Id);
    }

    public async Task<List<Product>> GetProductsByCategoryAsync(int categoryId)
    {
        string cacheKey = $"category_{categoryId}_products";

        return await _cache.GetOrCreateAsync(cacheKey, async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15);
            return await FetchByCategoryFromDatabaseAsync(categoryId);
        });
    }

    public void InvalidateAllProducts()
    {
        // Pattern-based invalidation (requires tracking keys)
        var keysToRemove = new List<string>
        {
            "product_list",
            // Add all known product keys
        };

        foreach (var key in keysToRemove)
        {
            _cache.Remove(key);
        }
    }

    private string GetCacheKey(int id) => $"product_{id}";

    private async Task<Product> FetchFromDatabaseAsync(int id)
    {
        await Task.Delay(50);
        return new Product { Id = id, Name = $"Product {id}", Price = 99.99m, CategoryId = 1 };
    }

    private async Task SaveToDatabaseAsync(Product product)
    {
        await Task.Delay(50);
    }

    private async Task<List<Product>> FetchByCategoryFromDatabaseAsync(int categoryId)
    {
        await Task.Delay(100);
        return new List<Product>
        {
            new Product { Id = 1, Name = "Product 1", Price = 99.99m, CategoryId = categoryId },
            new Product { Id = 2, Name = "Product 2", Price = 149.99m, CategoryId = categoryId }
        };
    }
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public int CategoryId { get; set; }
}

Invalidation gets complex with related data. When you update a product, you might need to invalidate category lists, search results, and detail pages. Consider using cache tags or maintaining a key registry for batch invalidation. For distributed scenarios, message queues can broadcast invalidation events to all servers.

Try It Yourself

This complete example shows a caching layer with multiple strategies and metrics tracking. You'll see how caching reduces load and improves response times.

Program.cs - Complete caching example
using Microsoft.Extensions.Caching.Memory;
using System.Diagnostics;

var cache = new MemoryCache(new MemoryCacheOptions());
var service = new DataService(cache);

Console.WriteLine("=== Caching Demo ===\n");

// First access - cache miss
var sw = Stopwatch.StartNew();
var data1 = await service.GetExpensiveDataAsync("user_123");
sw.Stop();
Console.WriteLine($"First call: {sw.ElapsedMilliseconds}ms - {data1}");

// Second access - cache hit
sw.Restart();
var data2 = await service.GetExpensiveDataAsync("user_123");
sw.Stop();
Console.WriteLine($"Second call: {sw.ElapsedMilliseconds}ms - {data2}");

// Different key - cache miss
sw.Restart();
var data3 = await service.GetExpensiveDataAsync("user_456");
sw.Stop();
Console.WriteLine($"Different user: {sw.ElapsedMilliseconds}ms - {data3}");

// Display metrics
Console.WriteLine("\n=== Cache Metrics ===");
service.DisplayMetrics();

class DataService
{
    private readonly IMemoryCache _cache;
    private int _cacheHits = 0;
    private int _cacheMisses = 0;

    public DataService(IMemoryCache cache)
    {
        _cache = cache;
    }

    public async Task<string> GetExpensiveDataAsync(string key)
    {
        if (_cache.TryGetValue(key, out string cachedValue))
        {
            _cacheHits++;
            return cachedValue;
        }

        _cacheMisses++;

        // Simulate expensive operation
        await Task.Delay(500);
        var value = $"Data for {key} at {DateTime.Now:HH:mm:ss}";

        var options = new MemoryCacheEntryOptions
        {
            SlidingExpiration = TimeSpan.FromSeconds(30),
            PostEvictionCallbacks =
            {
                new PostEvictionCallbackRegistration
                {
                    EvictionCallback = (k, v, reason, state) =>
                    {
                        Console.WriteLine($"\n[Cache] Evicted '{k}' - Reason: {reason}");
                    }
                }
            }
        };

        _cache.Set(key, value, options);
        return value;
    }

    public void DisplayMetrics()
    {
        var total = _cacheHits + _cacheMisses;
        var hitRate = total > 0 ? (_cacheHits * 100.0 / total) : 0;

        Console.WriteLine($"Total Requests: {total}");
        Console.WriteLine($"Cache Hits: {_cacheHits}");
        Console.WriteLine($"Cache Misses: {_cacheMisses}");
        Console.WriteLine($"Hit Rate: {hitRate:F1}%");
    }
}
CachingDemo.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.0" />
  </ItemGroup>
</Project>

Running the example:

  1. Create a new folder and save both files
  2. Run dotnet restore to install packages
  3. Run dotnet run to see caching in action
  4. Notice the dramatic speed difference between hits and misses
  5. Modify expiration times to see different behaviors

Expected output:

Console Output
=== Caching Demo ===

First call: 502ms - Data for user_123 at 14:30:15
Second call: 0ms - Data for user_123 at 14:30:15
Different user: 501ms - Data for user_456 at 14:30:16

=== Cache Metrics ===
Total Requests: 3
Cache Hits: 1
Cache Misses: 2
Hit Rate: 33.3%

Design Trade-offs & Alternatives

Caching introduces complexity and trade-offs you need to consider. Memory consumption increases with cache size, potentially affecting other parts of your application. Stale data becomes a risk if you don't handle invalidation properly. Cache warming on application startup can delay availability.

In-memory caching offers the best performance but doesn't share data between instances and loses everything on restart. Distributed caching provides consistency and persistence but adds network latency and infrastructure complexity. Two-tier caching combines both for optimal results.

Consider alternatives to caching. Sometimes optimizing your database queries, adding indexes, or restructuring data provides better results without cache complexity. CDNs handle static content better than application-level caching. Database query result caching might already solve your problem.

Monitor cache effectiveness before expanding it. Track hit rates, eviction reasons, and memory usage. A cache with low hit rates wastes resources. Focus caching efforts on proven bottlenecks. Start simple with in-memory caching and add distributed caching only when scaling requires it.

Remember that caching is an optimization, not a requirement. Build your application to work correctly without caching first. Add caching strategically where measurements show it provides meaningful benefit. This approach keeps your codebase maintainable while delivering performance improvements where they matter most.

Frequently Asked Questions (FAQ)

What's the difference between sliding and absolute expiration?

Absolute expiration removes cache entries after a fixed time regardless of access. Sliding expiration extends the lifetime each time you access the entry. Use sliding for frequently accessed data and absolute for time-sensitive data like session tokens or API rate limits.

When should I use distributed caching instead of in-memory caching?

Use distributed caching when you have multiple application instances that need to share cached data, when you need cache persistence across restarts, or when cache size exceeds available memory on a single server. In-memory caching works well for single-instance applications or instance-specific data.

How do I prevent cache stampede when cache expires?

Use locking to ensure only one thread regenerates expired cache data. You can implement this with SemaphoreSlim or use GetOrCreate with proper locking. Some libraries provide built-in stampede protection. Another approach is to refresh cache proactively before expiration.

What data should I cache and what should I skip?

Cache data that's expensive to compute or fetch, accessed frequently, and changes infrequently. Skip caching user-specific data with low reuse, rapidly changing data, or data that's already fast to retrieve. Consider the cost of cache misses versus the benefit of cache hits.

How do I handle cache invalidation across multiple servers?

Use distributed caching solutions like Redis where invalidation affects all servers automatically. Alternatively, implement a message bus to broadcast invalidation messages. Time-based expiration with reasonable TTL values can also work for eventually consistent scenarios.

Should I cache entire objects or just computed results?

Cache the most expensive part of your operation. If database queries are slow, cache the entity. If computation on that entity is expensive, cache the result. Consider serialization cost for distributed caches. Sometimes caching intermediate results provides the best balance.

Back to Articles