agentsclimarketplace

Caching strategies

Skill FaysilAlshareef/dotnet-ai-kit/skills/api/caching-strategies

Use when adding caching to .NET APIs or optimizing response times with distributed cache, output cache, or ETags.From its SKILL.md

Install
npx -y skills add FaysilAlshareef/dotnet-ai-kit --skill caching-strategies

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 4 stars4 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

11.4 KB, as published. Nobody here has run it

Caching Strategies

Core Principles

  • Cache as close to the consumer as possible (browser > CDN > reverse proxy > app)
  • Always set an explicit TTL — unbounded caches lead to stale data and memory pressure
  • Invalidate explicitly when underlying data changes — do not rely solely on expiry
  • Avoid serving stale data for writes — cache reads aggressively, invalidate on mutations
  • Use cache-aside pattern: check cache first, fall back to source, populate cache on miss

When to Use

  • Read-heavy endpoints where the same data is requested frequently
  • Reference data that changes infrequently (catalogs, config, lookups)
  • Expensive computations or aggregations (reports, dashboards)
  • Responses that are identical for many users (public content, product listings)
  • APIs behind rate-limited downstream services

When NOT to Use

  • User-specific data that changes on every request (real-time balances, live feeds)
  • Endpoints with strong consistency requirements (payment processing, stock trading)
  • Write-heavy workflows where invalidation cost exceeds cache benefit
  • Data with regulatory constraints that prohibit caching (PII in some jurisdictions)
  • Responses smaller than the cache overhead (trivial lookups)

Patterns

Output Caching

Server-side cache built into ASP.NET Core. Caches entire HTTP responses.

// Program.cs — register and enable output caching
builder.Services.AddOutputCache(options =>
{
    // Default policy: cache all GET/HEAD responses for 60s
    options.AddBasePolicy(p => p.Expire(TimeSpan.FromSeconds(60)));

    // Named policy with tag for invalidation
    options.AddPolicy("Products", p => p
        .Expire(TimeSpan.FromMinutes(5))
        .Tag("products"));

    // Per-user policy using Authorization header variation
    options.AddPolicy("UserSpecific", p => p
        .SetVaryByHeader("Authorization")
        .Expire(TimeSpan.FromSeconds(30)));
});

app.UseOutputCache();
// Minimal API — apply output cache policy
app.MapGet("/products", async (ISender sender, CancellationToken ct) =>
{
    var products = await sender.Send(new ListProductsQuery(), ct);
    return Results.Ok(products);
}).CacheOutput("Products");

// Controller — attribute-based
[HttpGet]
[OutputCache(Duration = 60, Tags = ["products"])]
public async Task<ActionResult<List<ProductResponse>>> GetAll(
    CancellationToken ct)
{
    var result = await sender.Send(new ListProductsQuery(), ct);
    return Ok(result);
}
// Tag-based invalidation using IOutputCacheStore
app.MapPost("/products", async (
    CreateProductCommand command, ISender sender,
    IOutputCacheStore cache, CancellationToken ct) =>
{
    var id = await sender.Send(command, ct);
    await cache.EvictByTagAsync("products", ct);
    return Results.Created($"/products/{id}", new { id });
});

Response Caching

Client-side caching via HTTP Cache-Control headers. The browser or CDN caches responses.

// Program.cs
builder.Services.AddResponseCaching();
app.UseResponseCaching();
// Controller with Cache-Control headers
[HttpGet]
[ResponseCache(Duration = 120, Location = ResponseCacheLocation.Any,
    VaryByHeader = "Accept")]
public async Task<ActionResult<List<CategoryResponse>>> GetCategories(
    CancellationToken ct)
{
    var result = await sender.Send(new ListCategoriesQuery(), ct);
    return Ok(result);
}

// No-cache for sensitive data
[HttpGet("me")]
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None,
    NoStore = true)]
public async Task<ActionResult<UserProfile>> GetProfile(
    CancellationToken ct)
{
    var result = await sender.Send(new GetProfileQuery(), ct);
    return Ok(result);
}

Distributed Cache

IDistributedCache backed by Redis, SQL Server, or NCache. Shared across app instances.

// Program.cs — Redis distributed cache
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration =
        builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "MyApp:";
});
// Service using IDistributedCache
public sealed class CachedProductService(
    IDistributedCache cache,
    IProductRepository repository)
{
    private static readonly DistributedCacheEntryOptions CacheOptions = new()
    {
        AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
        SlidingExpiration = TimeSpan.FromMinutes(2)
    };

    public async Task<ProductResponse?> GetByIdAsync(
        Guid id, CancellationToken ct)
    {
        var cacheKey = $"product:{id}";

        // Try cache first
        var cached = await cache.GetStringAsync(cacheKey, ct);
        if (cached is not null)
        {
            return JsonSerializer.Deserialize<ProductResponse>(cached);
        }

        // Fall back to database
        var product = await repository.GetByIdAsync(id, ct);
        if (product is null) return null;

        var response = product.ToResponse();
        await cache.SetStringAsync(
            cacheKey, JsonSerializer.Serialize(response),
            CacheOptions, ct);

        return response;
    }

    public async Task InvalidateAsync(Guid id, CancellationToken ct)
    {
        await cache.RemoveAsync($"product:{id}", ct);
    }
}

HybridCache (.NET 9+)

Two-tier cache: L1 in-process memory + L2 distributed. Built-in stampede protection.

// Program.cs — register HybridCache with Redis L2
builder.Services.AddHybridCache(options =>
{
    options.MaximumPayloadBytes = 1024 * 1024; // 1 MB
    options.MaximumKeyLength = 256;
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromMinutes(10),
        LocalCacheExpiration = TimeSpan.FromMinutes(2)
    };
});

// Add Redis as the L2 backing store
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration =
        builder.Configuration.GetConnectionString("Redis");
});
// Service using HybridCache — stampede-safe GetOrCreateAsync
public sealed class ProductService(
    HybridCache cache, IProductRepository repository)
{
    public async Task<ProductResponse> GetByIdAsync(
        Guid id, CancellationToken ct)
    {
        return await cache.GetOrCreateAsync(
            $"product:{id}",
            async token =>
            {
                var product = await repository.GetByIdAsync(id, token)
                    ?? throw new NotFoundException(
                        $"Product {id} not found");
                return product.ToResponse();
            },
            new HybridCacheEntryOptions
            {
                Expiration = TimeSpan.FromMinutes(5),
                LocalCacheExpiration = TimeSpan.FromMinutes(1)
            },
            cancellationToken: ct);
    }

    public async Task InvalidateAsync(Guid id, CancellationToken ct)
    {
        await cache.RemoveAsync($"product:{id}", ct);
    }
}

ETag / Conditional Requests

Return 304 Not Modified when content has not changed, saving bandwidth.

// ETag generation helper
public static class ETagHelper
{
    public static string Generate(object data)
    {
        var json = JsonSerializer.Serialize(data);
        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
        return $"\"{Convert.ToBase64String(bytes)}\"";
    }
}
// Controller with ETag support
[HttpGet("{id:guid}")]
public async Task<ActionResult<ProductResponse>> GetById(
    Guid id, CancellationToken ct)
{
    var product = await sender.Send(new GetProductQuery(id), ct);
    if (product is null) return NotFound();

    var etag = ETagHelper.Generate(product);

    if (Request.Headers.IfNoneMatch.Contains(etag))
    {
        return StatusCode(StatusCodes.Status304NotModified);
    }

    Response.Headers.ETag = etag;
    Response.Headers.CacheControl = "private, max-age=60";
    return Ok(product);
}

Cache Invalidation

Strategies to keep cached data consistent with the source of truth.

// Tag-based invalidation (Output Cache)
app.MapPut("/products/{id:guid}", async (
    Guid id, UpdateProductCommand command, ISender sender,
    IOutputCacheStore cache, CancellationToken ct) =>
{
    await sender.Send(command with { Id = id }, ct);
    await cache.EvictByTagAsync("products", ct);
    return Results.NoContent();
});
// Event-based invalidation with MediatR notification
public sealed record ProductUpdatedEvent(Guid ProductId) : INotification;

public sealed class InvalidateProductCacheHandler(
    HybridCache cache) : INotificationHandler<ProductUpdatedEvent>
{
    public async Task Handle(
        ProductUpdatedEvent notification, CancellationToken ct)
    {
        await cache.RemoveAsync(
            $"product:{notification.ProductId}", ct);
    }
}

Key invalidation approaches:

  • Tag-based — group related entries, evict all at once via EvictByTagAsync
  • Event-based — domain events trigger cache removal through MediatR handlers
  • TTL expiry — set AbsoluteExpiration for automatic cleanup
  • Manual eviction — call RemoveAsync directly in write endpoints

Decision Guide

ScenarioCache TypeWhy
Public GET, same response for all usersOutput CacheServer-side, zero client config
Static assets and CDN-friendly responsesResponse CacheCache-Control for browser/CDN
Shared data across multiple app instancesDistributed Cache (Redis)Centralized, survives restarts
High-throughput reads, local + shared needsHybridCache (.NET 9+)L1 speed + L2 consistency + stampede guard
Bandwidth-sensitive mobile clientsETag / Conditional304 saves payload transfer
Reference data (countries, currencies)HybridCache or Output CacheRarely changes, high read volume
User session or cart dataDistributed CachePer-user, shared across instances
Expensive aggregation queriesHybridCache with long TTLCompute once, serve many

Anti-Patterns

ProblemWhy It HurtsCorrect Approach
No TTL on cache entriesMemory grows unbounded, stale data foreverAlways set AbsoluteExpiration or SlidingExpiration
Caching behind auth without VaryUser A sees User B's dataSetVaryByHeader("Authorization") or skip output cache
Cache-then-write without invalidationReads return stale data after mutationsInvalidate or evict on every write path
Caching error responsesErrors served repeatedly from cacheOnly cache successful (2xx) responses
In-memory cache in multi-instance deployEach instance has different cache stateUse IDistributedCache or HybridCache
Stampede on cache miss (thundering herd)All requests hit DB simultaneouslyUse HybridCache.GetOrCreateAsync with stampede protection
Over-caching volatile dataUsers see outdated informationMatch TTL to change frequency; skip real-time data

References

Keep looking

Skills are one crate of 627,801. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.