CARVIEW |
Opened 3 months ago
Last modified 5 hours ago
#63636 accepted enhancement
Enable instant page navigations from browser history via bfcache when sending "nocache" headers
Reported by: |
|
Owned by: |
|
---|---|---|---|
Milestone: | Future Release | Priority: | normal |
Severity: | normal | Version: | 6.3 |
Component: | Administration | Keywords: | has-patch dev-feedback needs-unit-tests |
Focuses: | performance, privacy | Cc: |
Description (last modified by westonruter)
In #21938 and #61942 the no-store
directive was added to responses when nochache_headers()
is called. This happens with every authenticated response, and it may often be called when serving unauthenticated responses in e-commerce plugins, such as on the cart and checkout pages. This no-store
directive prevents proxies from caching the response so that it is not erroneously served to other users. This is a good, however no-store
has a downside: it disables back/forward cache (bfcache). This means that authenticated users can get a degraded experience since may not experience instant back/forward navigations enabled by the browser's bfcache. Furthermore, the lack of bfcache can result in data loss when data has been entered via a JavaScript-built UI since this state is lost when a page is not restored via bfcache. (See demo video in WooCommerce for a no-store
removal PR which is now merged.)
(There are other reasons for why bfcache may be disabled for a page. One example is the use of the unload event; this was removed in #55491 in order to enable bfcache, only to realize that no-store
had been recently added in #21938 so no navigation benefit was gained.)
The use of the no-store
directive to prevent proxies from caching responses is actually redundant in this way with the private
directive:
The
private
response directive indicates that the response can be stored only in a private cache (e.g., local caches in browsers).
You should add the
private
directive for user-personalized content, especially for responses received after login and for sessions managed via cookies.
If you forget to add
private
to a response with personalized content, then that response can be stored in a shared cache and end up being reused for multiple users, which can cause personal information to leak.
The no-store
directive also does this, but it prevents the browser from also caching the response in bfcache:
The
no-store
response directive indicates that any caches of any kind (private or shared) should not store this response.
Nevertheless, the disabling of the bfcache via no-store
was actually intentional in #21938. With bfcache there is a privacy concern where an authenticated user may log out of WordPress, only for another person to access the computer and click the back button in order to view the contents of the authenticated page loaded from the bfcache. In practice this issue depends on the user being on a shared computer, and it also requires the malicious user to act soon since the bfcache has a timeout (10 minutes in Chrome for pages sent without no-store
). As noted in a comment, it's not entirely clear if the increased privacy is worth the degraded user experience if the private
directive is already preventing proxies from caching the responses.
Nevertheless, the privacy concern can still be addressed without disabling bfcache. Protecting against restoring pages from bfcache after the user has logged out can be achieved as follows: When authenticating to WordPress, a "bfcache session token" cookie is sent along with the other authentication cookies. This cookie is not HTTP-only so that it can be read in JavaScript; it is a random string not used for any other purpose. When an authenticated page is served, a script is included which reads the value of this cookie. When a user navigates away from the page and then navigates back to it, a pageshow
event handler checks to see if it was restored from bfcache. If so, it checks the latest value of the cookie, and if it doesn't match it clears the contents of the page and initiates a page reload so that the contents are not available.
Related tickets:
Change History (15)
This ticket was mentioned in PR #9131 on WordPress/wordpress-develop by @westonruter.
3 months ago
#1
- Keywords has-patch added
#2
@westonruter
3 months ago
- Keywords dev-feedback needs-unit-tests added
- Status changed from assigned to accepted
#3
@westonruter
3 months ago
I've also formulated the patch in a standalone plugin: https://github.com/westonruter/nocache-bfcache
#4
@westonruter
3 months ago
I just learned of the Clear-Site-Data header as well.
Upon logging out, the following header could get sent:
Clear-Site-Data: "cache"
According to MDN, it says this "might" clear out the bfcache "depending on the browser". In testing with Chrome, however, this doesn't seem to work. So the JavaScript-based client-side bfcache invalidation still seems needed.
This remains an open question in w3c/webappsec-clear-site-data. There's another executionContexts directive which seems to have been intended for this purpose, but it is not (any longer) implemented by browsers.
This ticket was mentioned in Slack in #core-performance by westonruter. View the logs.
3 months ago
#6
@westonruter
3 months ago
- Description modified (diff)
#7
@westonruter
3 months ago
I've published a feature plugin to the dotorg directory: https://wordpress.org/plugins/nocache-bfcache/
Blog post with writeup: https://weston.ruter.net/2025/07/23/instant-back-forward-navigations-in-wordpress/
This ticket was mentioned in Slack in #core-performance by westonruter. View the logs.
3 months ago
This ticket was mentioned in Slack in #core-performance by westonruter. View the logs.
6 weeks ago
#10
follow-up:
↓ 11
@kkmuffme
6 weeks ago
1) Since the function is called "wp_get_nocache_headers()" I'd expect that there should be absolutely no caching including no bfcache be happening. This is why "no-store" should stay included.
Instead, maybe create a "wp_get_only_bfcache_headers()" (or whatever makes sense) which excludes "no-store" and uses "private" only.
2) In an ideal world it's +100 from me :-)
However:
The problem with those headers is always that proxy caches change if/which directives they adhere to and how.
Theoretically, you're right that according to https://datatracker.ietf.org/doc/html/rfc7234 just setting "private" without no-store should be enough to ensure it's not stored in any proxy caches.
Unfortunately, it is not in practice:
https://developers.cloudflare.com/cache/concepts/cache-control/#conditions
Cache-Control returned to eyeball does not include private.
This means that suddenly it's Cache-Control without no-store and without private.
Now it might happen that max-age is ignored too e.g. due to account limitations at the Edge (e.g. https://developers.cloudflare.com/cache/concepts/cache-control/#expiration) and you'll suddenly end up serving private, non-cacheable data to everybody.
Fyi: what's in Cloudflare's docs and what is actually happening is often 2 very different things that may also change without any notice from my experience working closely with Cloudflare's caches for a decade now.
While I wish this was a theoretical assumption only, it's been something that has been exploited a couple years back with some cache plugins/CDNs.
Since WP is used behind all kinds of set-ups, the only absolutely safe way is to actually always use both - no-store, private - for pages that are at risk of replay attacks (especially since WP's nonce, isn't a real nonce that can only be used once) and/or pages that contain private data.
3) the bfcache introduces a huge potential for data loss for data that can be used by multiple users (= anything in wp-admin)
e.g. user A opens page 123, then navigates to page 456.
User B changes the title of 123 from "Hello" to "World"
User A clicks the browser back button - it will show title 123 "Hello" - if the user now clicks "Update", it will restore the old title again.
This is bc https://web.dev/articles/bfcache
Note that when a page is restored from bfcache, it is restored from memory, not from the HTTP cache. As a result, directives like Cache-Control: no-cache or Cache-Control: max-age=0 are not taken into account, and no revalidation occurs before the content is displayed to the user.
Fyi Chrome is implementing this natively: https://developer.chrome.com/docs/web-platform/bfcache-ccns
Also see https://github.com/whatwg/html/issues/7189
#11
in reply to:
↑ 10
@westonruter
3 weeks ago
@kkmuffme Sorry for the delay in replying to you.
Replying to kkmuffme:
1) Since the function is called "wp_get_nocache_headers()" I'd expect that there should be absolutely no caching including no bfcache be happening. This is why "no-store" should stay included.
Except for most of this function's existence, from 2.8.0 until 6.3.0, it didn't include no-store
. The function has "nocache" in its name which to me implies the no-cache
directive.
2) In an ideal world it's +100 from me :-)
However:
The problem with those headers is always that proxy caches change if/which directives they adhere to and how.
I don't fully understand everything you've outlined here. But I will note that the Cloudflare docs there say: "private
— Indicates the response message is intended for a single user, such as a browser cache, and must not be stored by a shared cache like Cloudflare or a corporate proxy."
I don't understand the the Cloudflare docs there on the conditions. When it says “Browser Cache TTL is set” is this referring to the max-age
directive? It says if Origin Cache Control is disabled, “Cache-Control returned to eyeball does not include private.” (Aside: Strange to mention “eyeball” 👁️ and not “the client”.) Is a solution here then to make sure the Cache-Control
response header does not have max-age
?
3) the bfcache introduces a huge potential for data loss for data that can be used by multiple users (= anything in wp-admin)
Yes, this is a good callout. Nevertheless, the lack of bfcache also introduces a huge potential for data loss for all users as well, if they navigate away from a page without having saved a change, for example. But you're also right that there needs to be better accounting for updating stale data. I opened an issue for this a couple months ago: https://github.com/westonruter/nocache-bfcache/issues/25
I will say, however, that post locking should specifically deal with the scenario you're talking about.
Fyi Chrome is implementing this natively: https://developer.chrome.com/docs/web-platform/bfcache-ccns
Also see https://github.com/whatwg/html/issues/7189
Yes, but unfortunately cookie changes happen often, especially client-side via JS, which means this doesn't apply. And this is Chrome only.
And the Chrome docs there still say: “Best practice remains to minimize use of Cache-Control: no-store rather than depend on these heuristics.”
Another common reason I see bfcache being blocked is JsNetworkRequestReceivedCacheControlNoStoreResource
, where JavaScript on a page makes a request to a resource served with the no-store directive (e.g. REST API or admin-ajax). This happens everywhere in the wp-admin.
#12
@westonruter
3 weeks ago
Ideally the pageshow
event wouldn't need to be relied on to use JS to invalidate pages from bfcache. While this works, it is somewhat of a hack, and there remains a possible split-millisecond in which a previously-authenticated page can appear prior to the pageshow
event handler detecting the authentication state change and triggering a reload.
There's also an issue I discovered when using a service worker to serve pages with a NetworkFirst strategy, where a page gets served from cache if the network doesn't respond before a timeout occurs. This can result in pages stored in Cache by the service worker which have stale bfcache session token, and then every navigation which returns such a page in cache will immediately reload once served from cache. Not ideal.
The correct solution to protecting privacy is invaliding pages from bfcache using the Clear-Site-Data: "cache"
response header, as mentioned above. The benefit here is that there is no JavaScript involved at all. See also the feature plugin issue I created for this.
This header is well supported by browsers. However, there is currently a bug in Chromium (40233601) which causes page responses sent with this header to be very slow. I think this should be a blocker preventing this from moving forward until it is fixed.
This ticket was mentioned in Slack in #core-performance by westonruter. View the logs.
2 weeks ago
#14
@westonruter
5 hours ago
- Milestone changed from 6.9 to Future Release
Punting due to my previous comment about Clear-Site-Data: "cache"
not being ready yet in Chrome.
Trac ticket: https://core.trac.wordpress.org/ticket/63636