Caching patterns for .NET APIs — output caching, response caching, IDistributedCache, HybridCache, ETag conditional requests, and cache invalidation strategies. Trigger: cache, caching, output cache, distributed cache, Redis, ETag, HybridCache.
From dotnet-ai-kitnpx claudepluginhub faysilalshareef/dotnet-ai-kit --plugin dotnet-ai-kitThis skill uses the workspace's default tool permissions.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Sorts ECC skills, commands, rules, hooks, and extras into DAILY vs LIBRARY buckets using repo evidence like file extensions and configs. Creates trimmed install plan for project-specific needs.
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 |