Skip to main content

🔥 Caching Strategies in .NET — Explained with Code

Caching is one of the most important performance boosters in modern .NET applications.

Done right, it reduces database hits, improves response times, saves costs, and scales apps easily.

In this post, we’ll cover popular caching strategies in .NET with detailed examples and comments.


1️⃣ In-Memory Caching (IMemoryCache)

Best for: Single-server apps, small data, per-instance cache.
Storage: Inside the app process memory.
Downside: Cache is lost if app restarts, and not shared across multiple servers.

Example

// Program.cs
builder.Services.AddMemoryCache(options =>
{
    options.SizeLimit = 1024; // optional global limit
});

public class WeatherService
{
    private readonly IMemoryCache _cache;
    private readonly IHttpClientFactory _http;

    public WeatherService(IMemoryCache cache, IHttpClientFactory http)
    {
        _cache = cache;
        _http = http;
    }

    public async Task<WeatherDto> GetWeatherAsync(string city)
    {
        var key = $"weather:{city}";

        return await _cache.GetOrCreateAsync(key, async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
            entry.SlidingExpiration = TimeSpan.FromMinutes(2);
            entry.SetSize(1); // important when SizeLimit is set

            entry.RegisterPostEvictionCallback((k, v, reason, state) =>
            {
                Console.WriteLine($"Evicted {k} due to {reason}");
            });

            var client = _http.CreateClient("weather");
            return await client.GetFromJsonAsync<WeatherDto>($"https://api.example.com/{city}");
        });
    }
}

✅ Fastest caching strategy
❌ Not suitable for multi-server environments


2️⃣ Distributed Caching (IDistributedCache)

Best for: Multi-server apps, cloud-native apps, microservices.
Popular providers: Redis (recommended), SQL Server, NCache.

Configure Redis

// NuGet: Microsoft.Extensions.Caching.StackExchangeRedis
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "localhost:6379";
    options.InstanceName = "MyApp_";
});

Example: Cache-Aside with JSON Serialization

public class ProductService
{
    private readonly IDistributedCache _cache;
    private readonly IProductRepository _repo;

    public ProductService(IDistributedCache cache, IProductRepository repo)
    {
        _cache = cache;
        _repo = repo;
    }

    public async Task<Product?> GetProductAsync(int id)
    {
        var key = $"product:{id}";

        var cachedBytes = await _cache.GetAsync(key);
        if (cachedBytes != null)
        {
            return JsonSerializer.Deserialize<Product>(cachedBytes);
        }

        var product = await _repo.GetByIdAsync(id);
        if (product == null) return null;

        var bytes = JsonSerializer.SerializeToUtf8Bytes(product);
        await _cache.SetAsync(key, bytes, new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
        });

        return product;
    }
}

✅ Works across multiple servers
❌ Requires external dependency (Redis/SQL)


3️⃣ Cache-Aside Pattern (Lazy Loading)

How it works:

  1. App checks cache.
  2. If miss → fetch from DB → store in cache.
  3. Serve response.

Risk: Thundering herd (many requests missing at once).
Fix: Add locking or distributed locks.

private static readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();

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

    var sem = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
    await sem.WaitAsync();
    try
    {
        cached = await _cache.GetAsync(key);
        if (cached != null) return JsonSerializer.Deserialize<T>(cached);

        var value = await factory();
        await _cache.SetAsync(key, JsonSerializer.SerializeToUtf8Bytes(value),
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ttl });
        return value;
    }
    finally
    {
        sem.Release();
    }
}

4️⃣ Write-Through & Write-Behind Caching

  • Write-Through: Writes go to DB and cache immediately.
  • Write-Behind (Write-Back): Writes go to cache → persisted asynchronously later.

Example: Write-Behind (simplified)

public class CacheWriteBehindService : BackgroundService
{
    private readonly Channel<Product> _channel = Channel.CreateUnbounded<Product>();
    private readonly IProductRepository _db;

    public CacheWriteBehindService(IProductRepository db) => _db = db;

    public async Task EnqueueAsync(Product p) => await _channel.Writer.WriteAsync(p);

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var product in _channel.Reader.ReadAllAsync(stoppingToken))
        {
            await _db.SaveAsync(product); // async DB write
        }
    }
}

✅ High performance writes
❌ Risk of losing data if cache crashes before DB save


5️⃣ Output Caching (Whole Response)

ASP.NET Core 7+ has Output Caching Middleware.

// Program.cs
builder.Services.AddOutputCache();
app.UseOutputCache();

// Minimal API
app.MapGet("/time", () => DateTime.UtcNow).CacheOutput();

// Controller
[ApiController]
public class InfoController : ControllerBase
{
    [HttpGet]
    [OutputCache]
    public IActionResult Get() => Ok(new { Now = DateTime.UtcNow });
}

With Redis-backed output cache, multiple servers can share cached responses.

✅ Best for caching whole API responses
❌ Not flexible for per-user personalized data


6️⃣ Response Caching (via Headers)

Use Cache-Control, ETag, Last-Modified headers.

[HttpGet("{id}")]
public IActionResult GetResource(int id)
{
    var resource = _repo.Get(id);
    var etag = $"W/\"{resource.Version}\"";
    Response.Headers["ETag"] = etag;

    if (Request.Headers["If-None-Match"] == etag)
        return StatusCode(304);

    return Ok(resource);
}

✅ Best Practices

  • Always set TTL (avoid infinite caches).
  • Use versioned keys: product:v2:{id}.
  • Monitor hit/miss ratio in production.
  • Use Redis for distributed cache in real-world apps.
  • Prefer System.Text.Json or MessagePack for compact serialization.
  • Don’t cache sensitive user data unless encrypted.

🚀 Wrapping Up

We’ve explored:
✔️ In-Memory Cache
✔️ Distributed Cache (Redis, SQL, NCache)
✔️ Cache-Aside pattern
✔️ Write-Through / Write-Behind
✔️ Output & Response caching
✔️ Best practices

Caching is all about balancing speed, consistency, and memory usage.
Pick the right strategy depending on your app’s scale and requirements.

Comments

Popular posts from this blog

🏗️ Deep Dive: Understanding Every Concept in Microsoft Entra API Onboarding for .NET Developers

When working with Microsoft Entra (formerly Azure Active Directory), you’ll hear terms like App Registration, Tenant, Client ID, Audience, Scopes, Roles, Tokens, OBO flow , and more. If you’re new, it can feel overwhelming. This guide breaks down every key term and concept , with definitions, examples, and how they connect when you onboard and consume a new API. 🔹 1. Tenant Definition : A tenant in Entra ID is your organization’s dedicated, isolated instance of Microsoft Entra. Think of it like : Your company’s identity directory. Example : contoso.onmicrosoft.com is a tenant for Contoso Ltd. 🔹 2. App Registration Definition : The process of registering an application in Entra to give it an identity and permission to use Microsoft identity platform. Why needed : Without registration, Entra doesn’t know about your app. What it creates : Application (Client) ID – unique identifier for your app Directory (Tenant) ID – your organization’s ID Types of apps : Web ...

🗑️ Garbage Collection & Resource Management in .NET (C#) — Beginner Friendly Guide

When you start working with .NET and C#, one of the biggest advantages is that you don’t need to manually manage memory like in C or C++. The Garbage Collector (GC) does most of the work for you. But here’s the catch — not everything is managed automatically. Some resources like files, database connections, sockets, and native memory still need special handling. This blog will help you understand: ✔ How the GC works ✔ What are managed vs unmanaged resources ✔ The difference between Dispose , Finalize , and using ✔ The Dispose pattern with examples ✔ Best practices every C# developer should know 1) How Garbage Collection Works in .NET Managed resources → Normal .NET objects (string, List, etc.). GC frees them automatically. Unmanaged resources → External resources like file handles, database connections, sockets, native memory. GC cannot clean them up — you must do it. 👉 GC uses a Generational Model for performance: Gen 0 : Short-lived objects (local variables, t...

☁️ Azure Key vault Short Notes

🟢 What is Azure Key Vault? A cloud service for securely storing and accessing secrets, keys, and certificates . Removes the need to keep secrets (like connection strings, passwords, API keys) inside code or config files. Provides centralized secret management, encryption, and access control . 👉 Think of it like a secure password manager but for your applications. 🟢 Key Features Secrets → store text values (e.g., DB connection string, API key). Keys → store cryptographic keys (RSA, EC) for encryption, signing. Certificates → store/manage SSL/TLS certificates. Access Control → Access Policies (older model). Azure RBAC (modern, preferred). Integration → works with App Service, Functions, AKS, VMs, SQL DB, etc. Logging → audit who accessed secrets via Azure Monitor / Diagnostic Logs. 🟢 Why Use Key Vault? Security → secrets are encrypted with HSM (Hardware Security Modules). Compliance → meet industry standards (PCI-DSS, ISO, GDPR). Automation → aut...