Help us improve
Share bugs, ideas, or general feedback.
From dotnet-ai-kit
Use when adding caching to .NET APIs or optimizing response times with distributed cache, output cache, or ETags.
npx claudepluginhub faysilalshareef/dotnet-ai-kitHow this skill is triggered — by the user, by Claude, or both
Slash command
/dotnet-ai-kit:caching-strategiesWhen to use
When implementing caching strategies, output caching, or distributed cache patterns
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
- Cache as close to the consumer as possible (browser > CDN > reverse proxy > app)
Guides technical evaluation of code review feedback: read fully, restate for understanding, verify against codebase, respond with reasoning or pushback before implementing.
Share bugs, ideas, or general feedback.
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 });
});
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);
}
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);
}
}
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);
}
}
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);
}
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:
EvictByTagAsyncAbsoluteExpiration for automatic cleanupRemoveAsync directly in write endpoints| Scenario | Cache Type | Why |
|---|---|---|
| Public GET, same response for all users | Output Cache | Server-side, zero client config |
| Static assets and CDN-friendly responses | Response Cache | Cache-Control for browser/CDN |
| Shared data across multiple app instances | Distributed Cache (Redis) | Centralized, survives restarts |
| High-throughput reads, local + shared needs | HybridCache (.NET 9+) | L1 speed + L2 consistency + stampede guard |
| Bandwidth-sensitive mobile clients | ETag / Conditional | 304 saves payload transfer |
| Reference data (countries, currencies) | HybridCache or Output Cache | Rarely changes, high read volume |
| User session or cart data | Distributed Cache | Per-user, shared across instances |
| Expensive aggregation queries | HybridCache with long TTL | Compute once, serve many |
| Problem | Why It Hurts | Correct Approach |
|---|---|---|
| No TTL on cache entries | Memory grows unbounded, stale data forever | Always set AbsoluteExpiration or SlidingExpiration |
| Caching behind auth without Vary | User A sees User B's data | SetVaryByHeader("Authorization") or skip output cache |
| Cache-then-write without invalidation | Reads return stale data after mutations | Invalidate or evict on every write path |
| Caching error responses | Errors served repeatedly from cache | Only cache successful (2xx) responses |
| In-memory cache in multi-instance deploy | Each instance has different cache state | Use IDistributedCache or HybridCache |
| Stampede on cache miss (thundering herd) | All requests hit DB simultaneously | Use HybridCache.GetOrCreateAsync with stampede protection |
| Over-caching volatile data | Users see outdated information | Match TTL to change frequency; skip real-time data |