[API Proposal] Support modern Cache-Control directives in ResponseCacheAttribute and CacheProfile (s-maxage, stale-while-revalidate, stale-if-error) (RFC 9111, RFC 5861)
- Dominant language
- C#
- Stars
- 38.4k
- Forks
- 10.9k
- Avg merge
- 2d 5h
- Merged PRs (30d)
- 276
Description
## Background and Motivation
`[ResponseCache]` and `CacheProfile` can express `public`/`private`/`no-cache`, `max-age` and `Vary`, and that's it. That surface dates back to MVC 6 in 2015. Since then, a handful of `Cache-Control` response directives have become table stakes for anything running behind a CDN, each for a concrete reason:
- `s-maxage` ([RFC 9111 §5.2.2.10](https://www.rfc-editor.org/rfc/rfc9111#section-5.2.2.10)): browsers and edges have opposite constraints. A CDN cache can be purged in milliseconds, so the edge can safely hold a response for a long time. A browser cache can't be purged at all, so you want its TTL short. With only `max-age` you're forced to pick one number for both, and it ends up being the browser's (short) one, so every edge hit expires early and the CDN is mostly wasted.
- `stale-while-revalidate` ([RFC 5861](https://www.rfc-editor.org/rfc/rfc5861)): without it, TTL expiry means some unlucky user pays full origin latency while the cache refills (or the edge does request collapsing and a queue of users waits). With it, expiry costs nobody anything: the edge answers from the stale copy immediately and refreshes off the request path. Browsers implement it natively too.
- `stale-if-error` (RFC 5861): turns the CDN into a last-known-good buffer. A bad deploy, an origin outage or an overloaded upstream stops being a user-facing incident for the grace window, since the edge keeps serving what it has. This is cheap, declarative resilience that otherwise needs custom VCL/workers per CDN.
- `must-revalidate` (RFC 9111 §5.2.2.2): the opposite concern. Caches are allowed to serve stale content heuristically in some conditions (e.g.: when disconnected from the origin), and for inventory, pricing or ticket availability that's not acceptable. This directive forbids serving past expiry without revalidation.
- `proxy-revalidate` (RFC 9111 §5.2.2.8): the same guarantee, scoped to shared caches only. You can be strict at the CDN (which serves thousands of users from one entry) while leaving the single user's browser cache relaxed.
- `no-transform` (RFC 9111 §5.2.2.6): intermediaries and CDN features still recompress images and rewrite payloads in flight. When responses must stay byte-exact (signed content, checksummed downloads, anything a client validates against an ETag or hash), this is the directive that says hands off.
Current support, from the origin's `Cache-Control` header with no extra configuration:
- Fastly: [serving stale content](https://docs.fastly.com/en/guides/serving-stale-content) (`stale-while-revalidate`, `stale-if-error`), [s-maxage](https://docs.fastly.com/en/guides/how-caching-and-cdns-work)
- Cloudflare: [Cache-Control directives](https://developers.cloudflare.com/cache/concepts/cache-control/) (all six)
- CloudFront: [origin Cache-Control headers](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Expiration.html), [stale-while-revalidate / stale-if-error](https://aws.amazon.com/about-aws/whats-new/2023/05/amazon-cloudfront-stale-while-revalidate-stale-if-error-cache-control-directives/)
- Browsers: [`stale-while-revalidate` on MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#stale-while-revalidate) (Chrome, Firefox, Edge)
None of these directives can be set through the attribute or a cache profile today. The moment you need one, you have to stop using `[ResponseCache]` for that endpoint and write the header yourself in middleware.
That's what we ended up doing at SeatGeek for our ticketing APIs behind Fastly. It works, but it means the cache tiering logic lives in two places, and every endpoint that needs `s-maxage` silently opts out of the profile system. Judging by #60008, #62143, #2611 and #56769, other people keep running into versions of the same wall.
The frustrating part is that the framework already supports all of these directives: `Microsoft.Net.Http.Headers.CacheControlHeaderValue` has a `SharedMaxAge` property and an open `Extensions` collection. The only reason `[ResponseCache]` can't emit them is that `ResponseCacheFilterExecutor` builds the header with string concatenation and a three-way switch. So this is mostly a wiring exercise, not a design problem.
For comparison, other ecosystems already expose these declaratively: Spring through its `CacheControl` builder (`sMaxAge`, `staleWhileRevalidate`, `staleIfError`), Rails through `expires_in ..., stale_while_revalidate:`, Next.js/Vercel through route config. ASP.NET Core is the odd one out at the declarative layer, so this is catching up with the ecosystem rather than adding something new.
(#60008 asks for RFC 5861 support in the ResponseCaching middleware. This proposal is the other half: authoring the directives declaratively in MVC.)
## Proposed API
Property names follow `Microsoft.Net.Http.Headers.CacheControlHeaderValue`, which already models these directives (`SharedMaxAge` etc.), so the naming stays consistent across the framework.
```diff
namespace Microsoft.AspNetCore.Mvc;
public class CacheProfile
{
+ ///
+ /// Gets or sets the duration in seconds for which the response is cached by shared caches
+ /// (e.g. CDNs, proxies). Sets the "s-maxage" directive in the "Cache-control" header.
+ ///
+ public int? SharedMaxAge { get; set; }
+
+ ///
+ /// Gets or sets the duration in seconds for which a cache may serve a stale response
+ /// while revalidating it in the background. Sets the "stale-while-revalidate" directive (RFC 5861).
+ ///
+ public int? StaleWhileRevalidate { get; set; }
+
+ ///
+ /// Gets or sets the duration in seconds for which a cache may serve a stale response
+ /// when an error occurs during revalidation. Sets the "stale-if-error" directive (RFC 5861).
+ ///
+ public int? StaleIfError { get; set; }
+
+ ///
+ /// Gets or sets whether caches must revalidate stale responses before serving them.
+ /// Sets the "must-revalidate" directive in the "Cache-control" header.
+ ///
+ public bool? MustRevalidate { get; set; }
+
+ ///
+ /// Gets or sets whether shared caches must revalidate stale responses before serving them.
+ /// Sets the "proxy-revalidate" directive in the "Cache-control" header.
+ ///
+ public bool? ProxyRevalidate { get; set; }
+
+ ///
+ /// Gets or sets whether intermediaries are allowed to transform the response.
+ /// Sets the "no-transform" directive in the "Cache-control" header.
+ ///
+ public bool? NoTransform { get; set; }
}
public class ResponseCacheAttribute : Attribute, IFilterFactory, IOrderedFilter
{
+ ///
+ public int SharedMaxAge { get; set; }
+
+ ///
+ public int StaleWhileRevalidate { get; set; }
+
+ ///
+ public int StaleIfError { get; set; }
+
+ ///
+ public bool MustRevalidate { get; set; }
+
+ ///
+ public bool ProxyRevalidate { get; set; }
+
+ ///
+ public bool NoTransform { get; set; }
}
```
The attribute properties use the same nullable-backing-field pattern as `Duration` and `NoStore`, which is the part that makes "not set" and "explicitly set to 0" distinguishable:
```csharp
private int? _sharedMaxAge;
public int SharedMaxAge
{
get => _sharedMaxAge ?? 0;
set => _sharedMaxAge = value;
}
```
An unset property falls through to the named profile; an explicit `SharedMaxAge = 0` overrides the profile and emits `s-maxage=0`, which is a valid value (it forces immediate revalidation by shared caches while still allowing them to store the response). Durations are `int` seconds because attribute arguments can't be `TimeSpan` (the same constraint `Duration` already lives with).
On the implementation side, `SharedMaxAge` maps to `CacheControlHeaderValue.SharedMaxAge` directly, and `stale-while-revalidate`/`stale-if-error` are appended to `CacheControlHeaderValue.Extensions` as `NameValueHeaderValue` entries (with invariant-culture integer formatting), since the typed model keeps RFC 5861 directives in its extensions collection. Directive order within the emitted header follows `CacheControlHeaderValue.ToString()` and is not guaranteed; it shouldn't be relied upon.
If the review prefers a narrower first cut: `SharedMaxAge`, `StaleWhileRevalidate` and `StaleIfError` are the motivating set and stand on their own. `MustRevalidate`/`ProxyRevalidate`/`NoTransform` are severable and can be dropped or deferred without affecting the rest.
A few behavioral decisions worth calling out:
1. When none of the new properties are set, the executor keeps its current string-concatenation path and the output stays byte-for-byte identical, including the missing space in `public,max-age=10`. `CacheControlHeaderValue.ToString()` puts a space after each comma, which is equivalent per RFC 9110 but would break anyone asserting on the exact header string in their tests (and plenty of test suites do). The typed composition only kicks in when at least one new directive is present.
2. `NoStore = true` takes precedence over all the new directives, the same way it already takes precedence over `Duration`: they are ignored, nothing throws. This keeps `NoStore` usable as a kill switch on top of a profile that carries shared-cache directives.
3. The shared-cache-only directives (`SharedMaxAge`, `ProxyRevalidate`) combined with `Location = Client` throw `InvalidOperationException` at filter execution, in the same place the missing-`Duration` check throws today. `Location = Client` emits `private`, which instructs shared caches not to store the response, so those directives would have no effect: that's always a misconfiguration, and silently emitting it would be worse than failing. Note this is deliberately scoped to the shared-cache-only directives: `StaleWhileRevalidate`/`StaleIfError` on a `private` response are valid and intentionally allowed (browsers implement `stale-while-revalidate` for their local caches, see the MDN link above), as the per-user example below shows.
4. Small side effect of the rewrite: the current code emits a malformed `,max-age=N` when `Location` has an unrecognized value (the switch falls through to `null`). The typed path can't produce that.
#### Affected components
Razor Pages applies `[ResponseCache]` through its own internal `PageResponseCacheFilter`, which wraps the same executor: it picks all of this up with no additional public API (the internal filter's property mirrors get extended for consistency, covered by tests). Nothing else in the framework enumerates these directives.
## Usage Examples
Different TTLs for browser and edge, with staleness grace
```csharp
[ResponseCache(Duration = 300, SharedMaxAge = 300, StaleWhileRevalidate = 86400,
StaleIfError = 86400, Location = ResponseCacheLocation.Any)]
public IActionResult GetSiteConfiguration() => ...;
// Cache-Control: public, max-age=300, s-maxage=300, stale-while-revalidate=86400, stale-if-error=86400
```
Short-lived private caching for per-user data (stale-while-revalidate/stale-if-error don't require s-maxage)
```csharp
[ResponseCache(Duration = 5, StaleWhileRevalidate = 30, StaleIfError = 600,
Location = ResponseCacheLocation.Client)]
public IActionResult GetProfile() => ...;
// Cache-Control: max-age=5, private, stale-while-revalidate=30, stale-if-error=600
```
Cache profiles
```csharp
options.CacheProfiles.Add("EdgeCached", new CacheProfile
{
Duration = 900, SharedMaxAge = 900,
StaleWhileRevalidate = 86400, StaleIfError = 86400,
Location = ResponseCacheLocation.Any,
});
[ResponseCache(CacheProfileName = "EdgeCached")]
public IActionResult GetCaptions() => ...;
// Cache-Control: public, max-age=900, s-maxage=900, stale-while-revalidate=86400, stale-if-error=86400
```
Overriding a single profile value inline (same merge rules as Duration/NoStore today: attribute wins per property, the rest comes from the profile)
```csharp
[ResponseCache(CacheProfileName = "EdgeCached", SharedMaxAge = 60)]
public IActionResult GetEventList() => ...;
// Cache-Control: public, max-age=900, s-maxage=60, stale-while-revalidate=86400, stale-if-error=86400
```
Controller-level attribute (inherited by actions; an action-level [ResponseCache] replaces it entirely, existing most-effective-filter behavior, no merging)
```csharp
[ResponseCache(Duration = 60, SharedMaxAge = 120, StaleWhileRevalidate = 3600,
Location = ResponseCacheLocation.Any)]
public class CatalogController : ControllerBase
{
[HttpGet("/catalog/list")]
public string List() => ...;
// Cache-Control: public, max-age=60, s-maxage=120, stale-while-revalidate=3600
[HttpGet("/catalog/item")]
[ResponseCache(Duration = 10, Location = ResponseCacheLocation.Client)]
public string Item() => ...;
// Cache-Control: private,max-age=10 (unchanged legacy output, since no new directive is set)
}
```
Invalid combinations throw (the same guard applies to ProxyRevalidate with Location = Client)
```csharp
[ResponseCache(Duration = 10, SharedMaxAge = 60, Location = ResponseCacheLocation.Client)]
public IActionResult Broken() => ...;
// InvalidOperationException: The 'SharedMaxAge' property targets shared caches, but
// 'Location = Client' emits "private", which instructs shared caches not to store the
// response. The directive would have no effect.
```
## Alternative Designs
**Keep doing it in middleware.** This is the status quo and it does work. The problem is that it duplicates the location/duration logic MVC already owns, and you lose the profile system for exactly the endpoints where caching matters most. The number of issues asking for pieces of this (#60008, #62143, #2611) suggests a lot of teams have written the same middleware.
**A raw extensions string instead of named properties**, something like `Extensions = "stale-while-revalidate=86400"`. More future-proof, but it needs input validation to avoid header injection, can't be checked for nonsensical combinations, and doesn't match how the framework models known directives elsewhere (`CacheControlHeaderValue` gives them properties too). I left it out here, but nothing in this design forecloses it: an `Extensions` property remains a door that can be opened in a follow-up for genuinely custom directives.
**Include `immutable` (RFC 8246).** Asked for in #2611 and closed in 2020 for low demand, with the fair point that MVC responses are usually dynamic. It's a one-liner to add later if there's appetite. Left out of this set.
**`must-understand`** is specified to travel together with `no-store`, which collides with decision 3 above, so it's omitted.
**`TimeSpan` instead of `int` seconds** isn't possible on attributes. `int` also matches `Duration`.
## Risks
- No breaking changes: the existing code path isn't touched and its output is identical down to the byte (covered by parity tests asserting the exact strings). New behavior only happens when the new properties are used.
- The dead combinations (`s-maxage` or `proxy-revalidate` on a `private` response) throw instead of emitting a header CDNs would ignore, so the main misuse case fails fast and loudly. `NoStore = true` keeps its existing semantics and silently wins over everything, including the new directives.
- Performance: nothing changes on the existing path. Actions that opt into the new directives pay one `CacheControlHeaderValue` + `StringBuilder` per response, which is in the same ballpark as the strings being built anyway.
- The ResponseCaching middleware parses `Cache-Control` through the same typed model (`stale-while-revalidate`/`stale-if-error` land in its `Extensions` collection), and whether its *serving* logic should act on them is #60008, untouched here.
- Ships in the next major like any shared-framework API addition.
---
#### Live output
Captured from the working implementation: a small sample site running on Kestrel against locally-built bits, full response headers as curl sees them on the wire
```
$ curl -s -D - -o /dev/null http://127.0.0.1:5723/extended-public
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Thu, 11 Jun 2026 15:45:23 GMT
Server: Kestrel
Cache-Control: public, max-age=300, s-maxage=300, stale-while-revalidate=86400, stale-if-error=86400
Transfer-Encoding: chunked
$ curl -s -D - -o /dev/null http://127.0.0.1:5723/legacy-parity
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Thu, 11 Jun 2026 15:45:23 GMT
Server: Kestrel
Cache-Control: public,max-age=10
Transfer-Encoding: chunked
$ curl -s -D - -o /dev/null http://127.0.0.1:5723/private-burstable
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Thu, 11 Jun 2026 15:45:24 GMT
Server: Kestrel
Cache-Control: max-age=5, private, stale-while-revalidate=30, stale-if-error=600
Transfer-Encoding: chunked
```
Note the second response: an endpoint using only the pre-existing API keeps the historical output byte-for-byte (`public,max-age=10`, no space after the comma).
I have a working implementation of this: unit tests (including byte-for-byte parity for the legacy path), functional tests over TestServer, Razor Pages coverage, the full Mvc.Core test suite passing, and the sample above verified over HTTP with curl for every example in this proposal. If the shape looks right, are you open to a PR? Glad to open it as soon as the API review settles.
Contributor guide
Assessment
This issue has not been assessed yet.