👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
Avoiding Cache Stampede and Handling Nulls in .NET

Avoiding Cache Stampede and Handling Nulls in .NET

Author - Abdul Rahman (Bhai)

Caching

2 Articles

Improve

Table of Contents

  1. What we gonna do?
  2. Why we gonna do?
  3. How we gonna do?
  4. Summary

What we gonna do?

Your weather API gets hammered with 10,000 requests asking for the same city's forecast. Cache expires at 8 AM - peak traffic time. Without proper protection, all 10,000 requests slam your external API simultaneously, burning through your rate limits and costing you money.

In this article, we'll build production-grade caching patterns that prevent these disasters. You'll learn atomic cache operations, stampede prevention techniques, and defensive null handling - techniques that protect both your infrastructure and your budget.

We'll tackle three critical scenarios you'll face in production:

  • Caching missing or invalid data to prevent abuse and rate limit exhaustion
  • Using coordinated cache operations instead of naive get-then-set patterns
  • Preventing thundering herd problems when cache entries expire under load

Why we gonna do?

The Hidden Cost of Cache Misses

Imagine you're building a weather dashboard that displays forecasts for major cities. Your external API allows 1,000 requests per hour. During morning rush hour, 5,000 users request London's forecast. The cached entry just expired.


// Production Incident: 8:00 AM, London forecast cache expired
// Your naive implementation:

foreach (var user in 5000_concurrent_users)
{
    var forecast = cache.Get("weather:london");
    if (forecast == null)
    {
        forecast = await weatherApi.GetForecastAsync("london"); // $$$ API call
        cache.Set("weather:london", forecast);
    }
}

// Result:
// - 5,000 API calls in 2 seconds (you only have quota for 1,000/hour!)
// - API returns 429 Too Many Requests
// - Users see errors
// - You get a surprise $500 overage bill
            

The problem? Your code checks the cache, finds nothing, then calls the API - but hundreds of other requests are doing the exact same thing simultaneously. By the time request #1 saves the forecast to cache, requests #2-5000 have already started their API calls.

The DOS Attack Vector: Not Caching Failures

Here's an even worse scenario: someone discovers your API endpoint structure and starts hammering non-existent cities:


// Attacker sends automated requests:
GET /api/weather/NOTACITY12345
GET /api/weather/FAKEPLACE99999
GET /api/weather/INVALIDCITY777
// ... repeated 10,000 times per minute

// Your vulnerable code:
public async Task<WeatherForecast?> GetForecast(string city)
{
    var cacheKey = $"weather:{city}";
    var cached = await cache.GetAsync<WeatherForecast>(cacheKey);
    
    if (cached == null) // Always null for fake cities!
    {
        // Call external API every single time
        cached = await weatherApi.GetForecastAsync(city); // Returns null
        
        // Don't cache null - MAJOR SECURITY FLAW!
        if (cached != null)
        {
            await cache.SetAsync(cacheKey, cached);
        }
    }
    
    return cached;
}

// Impact:
// - External API gets 10,000 requests for non-existent cities
// - Your API key gets rate limited or banned
// - Legitimate users can't access weather data
// - This is a textbook Denial of Service (DOS) attack

            

Real-World Impact

In production environments, improper caching patterns lead to:

  • API cost overruns - Third-party API calls can cost $0.001-$0.01 each; 10,000 unnecessary calls = $10-$100 wasted
  • Rate limit violations - Getting your API key throttled or banned affects all users
  • Degraded user experience - Slow responses when external services are overloaded
  • Security vulnerabilities - Easy DOS attack vectors that competitors or bad actors can exploit
  • Infrastructure scaling costs - Auto-scaling triggers unnecessarily, increasing hosting bills

The solution? Defensive caching patterns that protect against these scenarios through coordination, negative caching, and atomic operations.

How we gonna do?

Solution 1: Always Cache the Result (Including Nulls)

The first defensive pattern: cache everything, even when data doesn't exist. Let's implement this for our weather API:


public class WeatherService
{
    private readonly IHybridCache _cache;
    private readonly IWeatherApi _weatherApi;
    
    public WeatherService(IHybridCache cache, IWeatherApi weatherApi)
    {
        _cache = cache;
        _weatherApi = weatherApi;
    }
    
    public async Task<WeatherForecast?> GetForecastAsync(string city)
    {
        var cacheKey = $"weather:{city.ToLowerInvariant()}";
        
        // GetOrCreateAsync provides automatic stampede protection through request coalescing:
        // - If cache key exists: return cached value immediately
        // - If cache key missing: first request executes factory, others wait for same result
        // - No locks needed - HybridCache handles coordination internally
        var forecast = await _cache.GetOrCreateAsync(
            cacheKey,
            async cancellationToken =>
            {
                // This factory runs ONCE even with 10,000 concurrent requests
                // Requests 2-10,000 wait for request #1's result instead of calling API
                var result = await _weatherApi.GetForecastAsync(city, cancellationToken);
                return result; // Can be null for invalid cities
            },
            new HybridCacheEntryOptions
            {
                Expiration = TimeSpan.FromMinutes(30),
                LocalCacheExpiration = TimeSpan.FromMinutes(10)
            }
        );
        
        return forecast;
    }
}

// Benefits:
// ✅ Invalid cities cached as null (prevents DOS)
// ✅ Only 1 API call per cache miss (coordination)
// ✅ Subsequent requests served from cache
// ✅ Rate limits protected
            

Solution 2: Different TTL for Negative Results

Sometimes you want shorter cache duration for missing data. Here's how to implement adaptive caching:


public async Task<WeatherForecast?> GetForecastWithAdaptiveTTL(string city)
{
    var cacheKey = $"weather:{city.ToLowerInvariant()}";
    
    var forecast = await _cache.GetOrCreateAsync(
        cacheKey,
        async cancellationToken =>
        {
            return await _weatherApi.GetForecastAsync(city, cancellationToken);
        },
        new HybridCacheEntryOptions
        {
            // Default: 30 minutes for valid data
            Expiration = TimeSpan.FromMinutes(30)
        }
    );
    
    // Adjust TTL for null results
    if (forecast == null)
    {
        await _cache.SetAsync(
            cacheKey,
            forecast,
            new HybridCacheEntryOptions
            {
                // Shorter: 5 minutes for invalid cities
                Expiration = TimeSpan.FromMinutes(5)
            }
        );
    }
    
    return forecast;
}

// Strategy:
// - Valid forecasts: 30-minute cache
// - Invalid cities: 5-minute cache (in case they add the city later)
// - Still prevents DOS (5 minutes is enough protection)
            

Solution 3: Proving Stampede Protection Works

Let's simulate 2,000 concurrent users requesting London's weather to demonstrate coordination:


using Microsoft.Extensions.Caching.Hybrid;
using System.Diagnostics;

public class WeatherStampedeSimulation
{
    private int _apiCallCount = 0;
    private readonly IHybridCache _cache;
    
    // Simulated external API call
    private async Task<WeatherForecast> CallExternalApiAsync(string city)
    {
        Interlocked.Increment(ref _apiCallCount);
        await Task.Delay(500); // Simulate network latency
        
        return new WeatherForecast
        {
            City = city,
            Temperature = Random.Shared.Next(15, 30),
            Condition = "Partly Cloudy"
        };
    }
    
    public async Task SimulateTrafficSpikeAsync()
    {
        const int concurrentUsers = 2000;
        const string city = "London";
        
        // Create 2000 concurrent tasks
        var tasks = Enumerable.Range(0, concurrentUsers)
            .Select(async _ =>
            {
                return await _cache.GetOrCreateAsync(
                    $"weather:{city}",
                    async ct => await CallExternalApiAsync(city)
                );
            })
            .ToArray();
        
        var sw = Stopwatch.StartNew();
        await Task.WhenAll(tasks);
        sw.Stop();
        
        Console.WriteLine($"Concurrent Users: {concurrentUsers}");
        Console.WriteLine($"External API Calls: {_apiCallCount}");
        Console.WriteLine($"Total Time: {sw.ElapsedMilliseconds}ms");
        Console.WriteLine($"Avg per Request: {sw.ElapsedMilliseconds / (double)concurrentUsers:F2}ms");
    }
}

// Output:
// Concurrent Users: 2000
// External API Calls: 1        ← Only ONE API call!
// Total Time: 523ms
// Avg per Request: 0.26ms      ← Lightning fast for cached requests
            

Solution 4: The Option/Maybe Pattern (No Nulls)

If you prefer avoiding nulls entirely, use the Option Pattern to explicitly represent presence/absence:


    {
        // Not in cache - get from database
        product = await database.GetProductAsync(productId); // May return null
        await cache.SetAsync(cacheKey, product); // Still won't cache null!
    }
    
    return product;
}

// Better: Can distinguish cache miss from null
// Problem: Still no stampede protection, separate operations
// Attack: Still vulnerable to repeated queries for non-existent items
            

// APPROACH 3: Cache null with GetOrSet (CORRECT!)
public async Task<Product?> GetProductApproach3(int productId)
{
    var cacheKey = $"product:{productId}";
    
    // Atomic operation that DOES cache null
    var product = await fusionCache.GetOrSetAsync(
        cacheKey,
        async _ => await database.GetProductAsync(productId) // Returns null if not found
    );
    
    return product; // May be null, but that's cached!
}
            

Solution 4: The Option/Maybe Pattern (No Nulls)

If you prefer avoiding nulls entirely, use the Option Pattern to explicitly represent presence/absence:


// Option type - represents "some value" or "no value"
public readonly record struct Option<T>
{
    private readonly T? _value;
    public bool IsSome { get; }
    public bool IsNone => !IsSome;
    
    private Option(T? value, bool isSome)
    {
        _value = value;
        IsSome = isSome;
    }
    
    public static Option<T> Some(T value) => new(value, true);
    public static Option<T> None() => new(default, false);
    
    public T ValueOr(T defaultValue) => IsSome ? _value! : defaultValue;
    
    public TResult Match<TResult>(
        Func<T, TResult> some,
        Func<TResult> none) => IsSome ? some(_value!) : none();
}

// Usage in weather service
public async Task<Option<WeatherForecast>> GetForecastOptionAsync(string city)
{
    var cacheKey = $"weather:{city.ToLowerInvariant()}";
    
    var option = await _cache.GetOrCreateAsync(
        cacheKey,
        async cancellationToken =>
        {
            var forecast = await _weatherApi.GetForecastAsync(city, cancellationToken);
            return forecast != null 
                ? Option<WeatherForecast>.Some(forecast)
                : Option<WeatherForecast>.None();
        }
    );
    
    return option;
}

// Consumer code - pattern matching
var optionForecast = await service.GetForecastOptionAsync("Paris");

var message = optionForecast.Match(
    some: forecast => $"{forecast.City}: {forecast.Temperature}°C",
    none: () => "City not found"
);

// Benefits:
// ✅ Explicit absence handling
// ✅ No NullReferenceException risk
// ✅ Still gets coordination & DOS protection
// ✅ Cache stores "None" state (not just null)
            

Production Monitoring: Detecting Cache Issues

In production, you need visibility into cache behavior. Here's how to instrument your caching layer:


using Microsoft.Extensions.Logging;
using System.Diagnostics;

public class MonitoredWeatherService
{
    private readonly IHybridCache _cache;
    private readonly IWeatherApi _weatherApi;
    private readonly ILogger<MonitoredWeatherService> _logger;
    private readonly IMeterFactory _meterFactory;
    private readonly Counter<long> _cacheHits;
    private readonly Counter<long> _cacheMisses;
    private readonly Counter<long> _apiCalls;
    private readonly Histogram<double> _apiLatency;
    
    public MonitoredWeatherService(
        IHybridCache cache,
        IWeatherApi weatherApi,
        ILogger<MonitoredWeatherService> logger,
        IMeterFactory meterFactory)
    {
        _cache = cache;
        _weatherApi = weatherApi;
        _logger = logger;
        
        var meter = meterFactory.Create("WeatherService");
        _cacheHits = meter.CreateCounter<long>("cache.hits");
        _cacheMisses = meter.CreateCounter<long>("cache.misses");
        _apiCalls = meter.CreateCounter<long>("api.calls");
        _apiLatency = meter.CreateHistogram<double>("api.latency.ms");
    }
    
    public async Task<WeatherForecast?> GetForecastAsync(string city)
    {
        var cacheKey = $"weather:{city.ToLowerInvariant()}";
        var sw = Stopwatch.StartNew();
        
        try
        {
            var forecast = await _cache.GetOrCreateAsync(
                cacheKey,
                async cancellationToken =>
                {
                    _cacheMisses.Add(1);
                    _logger.LogInformation("Cache MISS for {City}", city);
                    
                    var apiSw = Stopwatch.StartNew();
                    var result = await _weatherApi.GetForecastAsync(city, cancellationToken);
                    apiSw.Stop();
                    
                    _apiCalls.Add(1);
                    _apiLatency.Record(apiSw.ElapsedMilliseconds);
                    
                    _logger.LogInformation(
                        "External API call for {City} completed in {Ms}ms, Result: {HasValue}",
                        city, apiSw.ElapsedMilliseconds, result != null);
                    
                    return result;
                },
                new HybridCacheEntryOptions
                {
                    Expiration = TimeSpan.FromMinutes(30)
                }
            );
            
            if (sw.ElapsedMilliseconds < 10) // Fast response = cache hit
            {
                _cacheHits.Add(1);
                _logger.LogDebug("Cache HIT for {City}", city);
            }
            
            return forecast;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error fetching forecast for {City}", city);
            throw;
        }
    }
}

// Metrics exposed via OpenTelemetry:
// - cache.hits: Count of cache hits
// - cache.misses: Count of cache misses  
// - api.calls: External API invocations
// - api.latency.ms: API response time distribution

// Use Grafana/Prometheus to alert on:
// - High cache miss rate (>20%)
// - Spike in API calls (possible stampede)
// - High API latency (>1000ms)
            

Decision Matrix: Which Pattern to Use?


╔════════════════════════╦══════════════════════╦══════════════════════════════╗
║ Scenario               ║ Recommended Pattern  ║ Why?                         ║
╠════════════════════════╬══════════════════════╬══════════════════════════════╣
║ Public-facing API      ║ GetOrCreate + Null   ║ DOS protection critical      ║
║                        ║ Caching              ║                              ║
╠════════════════════════╬══════════════════════╬══════════════════════════════╣
║ Internal services      ║ GetOrCreate + Option ║ Better type safety           ║
║                        ║ Pattern              ║                              ║
╠════════════════════════╬══════════════════════╬══════════════════════════════╣
║ High-traffic endpoints ║ HybridCache with     ║ Built-in L1+L2 caching       ║
║                        ║ Redis                ║                              ║
╠════════════════════════╬══════════════════════╬══════════════════════════════╣
║ Paid external APIs     ║ Adaptive TTL +       ║ Cost optimization            ║
║                        ║ Monitoring           ║                              ║
╠════════════════════════╬══════════════════════╬══════════════════════════════╣
║ Rapidly changing data  ║ Short TTL +          ║ Freshness vs performance     ║
║                        ║ Background refresh   ║ balance                      ║
╚════════════════════════╩══════════════════════╩══════════════════════════════╝

Library Recommendations:
┌─────────────────────────────────────────────────────────────────────────────┐
│ .NET 9+        → HybridCache (built-in, excellent coordination)             │
│ .NET 6-8       → FusionCache (battle-tested, rich features)                 │
│ Simple caching → MemoryCache (no stampede protection, use carefully)        │
│ Distributed    → HybridCache + Redis (multi-tier, shared across instances)  │
└─────────────────────────────────────────────────────────────────────────────┘
            

Summary

We've built production-grade caching patterns that protect against common disasters:

  • Negative Caching - Cache null/missing results to prevent DOS attacks from repeated queries for non-existent data, protecting API rate limits and reducing costs
  • Atomic Operations - Use GetOrCreate/GetOrSet instead of separate Get+Set calls to enable automatic coordination that prevents stampede conditions
  • Request Coordination - Libraries like HybridCache and FusionCache ensure only one request executes expensive operations while others wait for the shared result
  • Adaptive TTL - Apply different cache durations for successful vs failed lookups to balance freshness with protection
  • Option Pattern - Eliminate null reference issues while maintaining full stampede protection through explicit presence/absence types

Production-ready implementation checklist:

  • Use HybridCache (.NET 9+) or FusionCache for automatic coordination
  • Always cache the result - including null, Option.None, or error states
  • Instrument with metrics - track cache hits, misses, API calls, and latency
  • Set up alerts - detect stampede events and low hit rates in production
  • Consider adaptive TTL - shorter duration for negative results
  • Test under load - simulate concurrent traffic to verify coordination works
  • Monitor costs - track external API usage to catch inefficient caching

These patterns transform caching from a potential vulnerability into a robust defense layer. Your infrastructure stays healthy during traffic spikes, your API costs remain predictable, and malicious actors can't exploit cache misses to DOS your services. Implement them correctly, and your system will handle Black Friday traffic with the same ease as a quiet Tuesday afternoon.

👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
  • Caching
  • Cache Stampede
  • GetOrSet Pattern
  • Null Handling
  • DOS Protection
  • FusionCache
  • HybridCache
  • Cache Coordination
  • Result Pattern
  • .NET Caching