Proposal for a `Reload-If-Invalidated` header

Motivation

To allow content to be cached liberally for performance, for the 99% case, but revocable in the rare event of a strong need, like defacement or security vulnerability.

Cache invalidation is a common feature that intermediaries provide for similar purposes, but it is unavailable to widely distributed caches like the browser’s HTTP cache, any service worker caches, and signed exchanges (which may be served from any HTTPS cache until expiry).

Proposal

If a cached response to a GET request contains both of:

  • Reload-If-Invalidated: true
  • ETag and/or Last-Modified

then the browser sends a low-priority conditional request for the same URL along with a request header Purpose: reload. If the response completes and is not 304, then the browser reloads the resource using the new response.

This would apply to main resources (asynchronously reloading the whole page) and to subresources. For some subresources (e.g. JS), that would necessitate reloading the whole page, while for others (e.g. images), perhaps they could be replaced in-document.

Difficulties

I imagine there’s lots of cases to work out, e.g.:

  • Is beforeunload fired? What if a handler calls preventDefault?
  • If the reload response arrives while the dialog is open for location.href = prompt("where next?"), which navigation takes precedent?

Similarly for in-document replacement of images: If the reload response finishes before the original response, is an error event fired? etc.

Limitations

Because the reload could be a jarring experience to the user, it’s something that the server would want to do sparingly. That is, if the browser sends an If-None-Match, the server would probably want to 200 only if the etag matches a particularly bad version, e.g. not for a small typo fix. (Presumably an intermediary would send 200 only if the version was explicitly invalidated by its customer, and not if merely expired.)

This differs from a normal conditional fetch, hence the Purpose: reload. I’m not wild about the developer ergonomics – it’s easy to ignore the Purpose header and accidentally reload too often. But at least sending Reload-If-Invalidated is a signal that you’ve made the requisite server changes. And I’m not wild about the alternatives I’d thought of (different HTTP verb, different URL, a special “yes, reload” response code).

Alternatives considered

Signed exchange purge proposals

In the case of SXGs, there have been a couple of proposals. This one differs in a few different ways, which I think help ease the DX of this feature:

  • It’s not SXG-specific. This helps ensure it’s deployed correctly because it’ll be triggered in more scenarios.
  • It’s optional (for the sake of backwards-compatibility with existing SXGs). This helps the site do a gradual rollout of the feature and monitor for issues.
  • It fails open (for the sake of connection resilience). This enables the site to deploy without reducing its availability commitment.

That said, it doesn’t preclude future development of other proposals, for which a different set of properties is wanted.

<script>if (askOriginWhetherToReload()) location.reload()

A few of the disadvantages of a script tag:

  • Doesn’t solve the security vulnerability use case; an XSS attack would be able to remove it.
  • Often not able to be added by intermediaries; their customers are often OK with them adding HTTP headers but not HTML.
  • Doesn’t work for non-HTML resources (e.g. image, PDF).
  • The above naive implementation would be slow, resulting in two requests for the updated content. It might be possible to make a more efficient version using SW’s but it’d be difficult and add page weight.
  • It would require maintenance to keep its behavior in sync with the web platform (e.g. if a new ETag-like header is added).

Do nothing; recommend short max-age

This would trade off the performance (and possibly serving cost) of the 99% pageview for the freshness of the rare pageview. For some uses, that decision might make sense, but I think the above proposal would be at least as useful, and hence worth adding as an option.

WDYT?

Does the motivation make sense to you? Is this feasible to specify and implement? Do the benefits outweigh the costs? Can you suggest improvements? Thanks!

2 Likes

I initially missed this proposal, but I find it super interesting!

It’s trying to use current caching building blocks to (almost) enable in-browser “hold-till-told” caching, which is something I’ve given some thought to in the past.

A few questions/comments:

  • IIUC, you’re expecting browsers to use resources from the cache as if they are valid and then reload them in the rare case they are not. Is that correct?
  • How would reloads look like for subresources? It seems like some can simply reload the resource, while other would need to reload the entire page. That may give such subresources power they don’t currently have, so will need to be considered carefully.
  • I think that reusing the current conditional request validators here may be confusing and cause issues. Specifically, I suspect that the part of “the server would want to do sparingly” will not happen with current server implementations and those new semantics bolted on the older concepts of validators and 304 responses. IMO, it’d be better to mint a new “Is this still valid” conditional request field, and have adapted servers respond to that specifically. Hopefully non-supporting-servers will not send “Reload-If-Invalidated” headers to begin with.

This would be fantastic for images, which are relatively easy to reload.

This feature seems like a logical extension of stale-while-revalidate behavior. I think it’d make sense to make it dependent on stale-while-revalidate instead of it itself instructing caches to use stale resources. Browsers should keep using existing caching rules to decide when to use stale responses, and when to revalidate, and this feature should only control what happens after revalidation, not the caching behavior.

Max-age given in stale-while-revalidate serves an important purpose of avoiding using unreasonably old responses. Site author may decide that using yestarday’s response is probably fine, but a year-old response may be too jarring to even try.

Thanks for the comments!

Oops, I was ambiguous here. My proposal is that browsers would use resources from the cache if they are valid, not as if. This async reload would occur during t=0..(max-age+s-w-r). That said, see my reply to @kornel below.

It could be possible for the browser to do as if, but I guess that is equivalent to the origin saying Reload-If-Invalidated: true\r\nCache-Control: max-age=2147483648,s-maxage=..., so I’m thinking we might as well let the origin decide.

Good point; this seems critical. Potential mitigations (order of increasing flexibility):

  • Any subresource can reload-in-place. Only same-origin subresources can reload the whole page.
  • The reload-if-invalidated header value on the main resource is a list of origins that are allowed to reload.
  • The reload-if-invalidated header value on the main resource is CSP-style mapping from capability (“reload in-place” or “reload entire page”) to source lists.

Off-hand, CSP source lists could be useful for a big organization to have cross-departmental dependencies without that power. But perhaps a less flexible option is good enough for most.

Yeah, on further thought, there’s three pieces to this:

Introduce a new invalidation-token response header instead of reusing etag/last-modified? This seems OK to me; it’s strictly more general. I was thinking most sites would reuse their etag for this value, as it’s an already well-tested version identifier, but I dunno:

Maybe it’s not well-tested enough, because the cost of a false “modified” verdict is extra TTFB, whereas the cost of a false “invalidated” verdict would be a (likely user-visible) reload. However, it should be harder to return a false “invalidated”. e.g. If your server sends a random etag on every response, then if-none-match would ~always be true, but it’d ~never show up in your database of invalidated etags.

Introduce a new if-token-invalidated request header instead of reusing if-none-match/if-modified? This is also strictly more general, so it seems OK to me. However, it doesn’t address the “the server would want to do sparingly” case. Existing servers would ignore this new header and send 200 all of the time.

Make “do sparingly” the default: I’d guess a new status code 209 Invalidated would be the easiest for people to implement using existing servers, but again I dunno.

I like the idea of introducing a don’t-bother-reloading window (at t=[0, max-age]) when the resource is so new that it’s not worth the server traffic. (For SXG to work well with this, it would need to monkeypatch date_value in age computation to prefer the signature’s date param for reasons, but this seems feasible.)

An even more flexible approach would be to decouple the end of the dont-reload window from max-age by introducing a new reload-until parameter. This allows you to gradate the cache entry from “use it” (0…max-age) to “use it but quietly refresh for next time” (…stale-while-revalidate) to “use it but maybe immediately reload” (…reload-until) to “don’t use it”.

This flexibility might also be useful to differentiate the instructions you give to browsers (reload-until) to from those that also apply to public caches (stale-while-revalidate). That said, there’s other ways to do that, so :man_shrugging:.

Thanks for pointing me at this @yoavweiss.

When I think about invalidation protocols, I usually try to break it down into two steps:

  1. How to communicate an invalidation event
  2. How to target the invalidation at a chosen set of stored responses

This proposal uses a background check to initiate invalidation, and then couples that with a page reload (AIUI). It might be worth thinking about decoupling those, to give greater flexibility. It might be good to think about alternative sources of invalidation events too, like POST and other state-changing requests, sWr (as mentioned), etc. I really like the async piggybacking here – it’s an old idea :slight_smile:

Then, AIUI it uses the browser’s page context to decide how widely to apply the invalidation/reload. That information is only available to the browser, though – not the server, and not to intermediaries. That’s why headers like Surrogate-Key and Cache-Tag have become prevalent from CDNs; one of these days we’ll get around to standardising that. Would that sort of approach be interesting to you?

Beyond that, two observations:

  1. One of the design goals for stale-while-revalidate was to create no extra requests, so that we don’t waste user’s bandwidth, increase server load, or create opportunity cost for performance. I’m not sure I completely understand how this will work in practice, but it seems like it’s going to create a new request for each cache access.

  2. How this will interact with caches that don’t understand the extension needs to be carefully thought through. You can’t expect every implementation to simultaneously update.

Interesting. Perhaps one way to decouple those would be a payload body in the 209 Invalidated response suggesting a course of action. A few potential axes:

  • whether to reload in-place (if possible), whole page, or just freshen for the future (swr)
  • which browser cache tiers to target (SW cache, HTTP cache, etc.)
  • other URLs to invalidate (URL pattern, surrogate-key / cache-tag, etc.)

Are you primarily interested in the last one, or do you think it’s worth exposing knobs for all of them?

These all seem like generalizations of the original concept, so they’re OK by me. I’m all for making sure the design allows for such parameterization (even if only reserved for future iteration).

I guess the use case for one invalidation response to target multiple URLs in the browser is perceived latency. If the server knows that resources X & Y are invalid, then informing the browser allows it to make those requests immediately upon a subsequent load, rather than async via reload-if-invalidated. Is that the use case you were thinking?

Yep. The mitigations for this are that it requires opt-in from the server, and the async request has a Purpose: reload so the TLS termination proxy can throttle/ignore/isolate the requests as needed. But I’m open to other ways to invalidate distributed (and possibly obstinate) caches.

Good point. My thought was that this specifies how browsers behave only. (I can’t think of what intermediaries could do differently than what’s specified by s-maxage and s-w-r.)

If this feature encourages sites to increase their max-age, then browsers that don’t implement it would have staler responses than present. To that extent, it might make the most sense for an initial release to have a narrow set of targets, e.g.:

  • SW, since it’s client-side programmable. Sites could use JS to increase their max-age only for supporting browsers. (I guess that calls for a feature detection API.)
  • SXG, since it currently has one implementing engine and a backup means of revalidation (<script>).

(Re: the HTTP cache: I guess it could be possible, if difficult, for a server to set a different max-age if the browser sent a Sends-Reload-Requests header. I’m not sure if the browser should, but I guess that decision & design would best be folded into Client Hints, so probably best not to block an MVP of this proposal on that.)