Instant Back/Forward Navigations in WordPress

The new No-cache BFCache plugin enables instant back/forward navigations, particularly while logged in. See demos below for the impact this has on browsing.

The speed of page navigations in WordPress has seen a big boost in 6.8 with the introduction of Speculative Loading. When paired with the original feature plugin, site visitors can experience instant page navigations thanks to its opt-ins for prerendering and “moderate eagerness”. However, WordPress does not enable such instant navigations by default. WordPress core must take a conservative approach since prerendering may cause compatibility problems, like with analytics or ads. There is also a sustainability concern with moderate prerendering since a user may momentarily hover over a link but not intend to follow it at all; this results in an unused prerender, which can waste a user’s bandwidth (and CPU to a limited extent) as well as increase the load on the web server (which can be problematic on shared hosts without page caching). Furthermore, even when prerendering is enabled, not all visitors may be able to experience instant page loads because: 

  1. Moderate eagerness is limited on mobile devices (where there is no hover heuristic on touch screens).
  2. Speculative Loading is not enabled by default when a user is logged in.
  3. The Speculation Rules API is currently only supported in Chromium, leaving Safari and Firefox users out.

Nevertheless, there is a much older web platform technology that enables “prerendering” and which is supported in all browsers: the back/forward cache (bfcache). This instant page navigation involves no network traffic and no CPU load. Previously visited pages are stored in memory as a snapshot with their entire state so that they can be restored instantly. Navigating to pages stored in bfcache is as fast as switching open browser tabs.

Back/forward history navigations are very common, as according to the web.dev article on bfcache:

Chrome usage data shows that 1 in 10 navigations on desktop and 1 in 5 on mobile are either back or forward. With bfcache enabled, browsers could eliminate the data transfer and time spent loading for billions of web pages every single day!

Also learn more via the following video:

While Speculative Loading enables prerendering, bfcache involves “previous-rendering”.

The good news is that 84% of WordPress origins in HTTP Archive are already bfcache-eligible (based on this query from Gilberto Cocchi). However, the most frequent user of your site may still not be able to experience instant page navigations via bfcache: you! While browsing around the WP Admin and navigating the frontend of your site, the pages most likely aren’t eligible for bfcache due to various blocking reasons. This sluggish experience extends not only to administrators but also to users of sites which require authentication, including e-commerce (e.g. WooCommerce), social (e.g. BuddyPress), and any membership sites. The 84% metric above doesn’t take into account these types of WordPress sites since HTTP Archive exclusively crawls public URLs without authentication. Users browsing WordPress sites on shared hosts or when on a slow connection will be especially frustrated by slow back/forward navigations. Enabling instant back/forward navigations will not only make your users happier (especially you), but it can also improve your CWV passing rate in CrUX.

I’ve been on a mission to bring back bfcache (or rather to bring it forward).

Stale Content in Page Caches

Before going further, I wanted to call out a potentially negative consequence of navigating cached pages. While instant back/forward navigations can provide a big performance boost to the user experience, they can also be a source of confusion when navigating to a cached page with stale content.

Consider the case of an e-commerce site, where a user is on the shopping cart page. From there, they click a link to a related product, and from that product page they add it to the cart dynamically (e.g. via Ajax). At this point, if they hit the back button to return to the cart page and the cart is restored from a page cache, they may not see the newly added product in the cart. A user can simply reload the page to fix this, but this can also be handled automatically.

As described in the “Update stale or sensitive data after bfcache restore” section of the web.dev post, the pageshow event with the persisted property (further discussed below) indicates when a page was restored from bfcache. This event can be used to update the DOM with the latest shopping cart details. In fact, WooCommerce already implements this for its cart:

const refreshCachedCartData = ( event: PageTransitionEvent ): void => {
	if ( event?.persisted || getNavigationType() === 'back_forward' ) {
		dispatch( cartStore ).invalidateResolutionForStore();
	}
};
/* ... */
window.addEventListener( 'pageshow', refreshCachedCartData );Code language: TypeScript (typescript)

Simply searching a plugin’s codebase for “pageshow” (e.g. in Woo) should indicate whether bfcache navigations are accounted for.

Note also that stale content is also an issue we’ve faced with Speculative Loading, especially when using non-conservative eagerness.

Breaking Caching to Preserve Privacy

My bfcache work began two years ago when I started collaborating on #55491 to eliminate the use of the deprecated unload event handler in WordPress core. Not only does this event fire unreliably, but it also has the side effect of making a page ineligible for bfcache via the “unload-listener” blocking reason. I had queried HTTP Archive myself at the time, and I found it to be the second most common blocking reason. So in r56809 we eliminated all uses of unload, including from:

  • Heartbeat API
  • Post locking in the classic editor
  • Post previews

However, even with the use of unload eliminated, I was confused why I wasn’t finding pages to be eligible for bfcache in my testing. (The removal of unload in core likely didn’t make a dent in bfcache eligibility in HTTP Archive since only 0.25% of unauthenticated pages had the Heartbeat API present.) It turns out that several weeks before I started working on this, a commit had landed which intentionally broke browser page caching (both bfcache and HTTP cache): the no-store directive was added to the Cache-Control response header for logged-in users. (Specifically, this was added to the wp_get_nocache_headers() function.) This introduced the “response-cache-control-no-store” bfcache blocking reason, which was actually the most common reason for ineligibility, just above unload in my HTTP Archive query. The reason for adding this is found in #21938 which proposed adding the no-store directive in order to explicitly disable caching so that authenticated pages do not get stored in the browser cache. This was followed up with #61942 to also send no-store on the frontend while a user is logged in. The rationale for this is rooted in privacy, per the commit message:

The intention behind this change is to prevent sensitive data in responses for logged in users being cached and available to others, for example via the browser history after the user logs out.

Consider an administrator who is logged in to WordPress on a shared computer and is working on something sensitive, like managing API keys on an admin screen. A malicious person could navigate back to that admin screen via the back button after the user had logged out. Nevertheless, the “bad guy” will need to act fast because a browser won’t store cached pages indefinitely; Chrome, for example, holds onto pages in bfcache for 10 minutes. (Tip: When using a shared computer, always exit the browser after logging out of all sites when ending work.) So while no-store here does improve privacy, it does so at the expense of the user experience by degrading the performance of back/forward navigations. Even when pages aren’t eligible for bfcache, removing no-store still improves back/forward navigation performance since pages can be served from the browser’s HTTP cache (although the DOM has to be re-built and scripts have to be re-executed).

Surely there must be a way to preserve privacy and promote performance together.

It’s important to note that the no-store directive not only prevents pages from being cached by the browser, but it also preserves privacy by preventing proxies from caching pages, per MDN:

The no-store response directive indicates that any caches of any kind (private or shared) should not store this response.  

Keeping authenticated pages out of page caches prevents embarrassing scenarios like serving a user the shopping cart page for another user. Nevertheless, there is a more tailored Cache-Control mechanism for this purpose: the private directive. Again, per MDN:

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.

This is what was originally requested in #57627, but both private and no-store were added together, with the commit message reasoning:

The private directive complements the no-store directive by specifying that the response contains private information that should not be stored in a public cache. Some proxy caches may ignore the no-store directive but respect the private directive, thus it is included.

So how can the private directive be retained for proxies, while removing the no-store directive so that a user can benefit from browser caching but not at the expense of privacy?

Preserving Privacy while Caching

In order to omit the no-store directive and enable instant back/forward navigations while preserving privacy, a way is needed to evict pages from the browser cache when a user logs out. In my research, there are three mechanisms to do this, with various degrees of browser support.

The Clear-Site-Data Header

The most straightforward mechanism to invalidate pages from bfcache would at first seem to be the Clear-Site-Data HTTP response header. This is supposedly Baseline 2023 Newly Available, but there is an asterisk: “Some parts of this feature may have varying levels of support.” Per MDN, this header “sends a signal to the client that it should remove all browsing data of certain types (cookies, storage, cache) associated with the requesting website.” In fact, the cache directive for this header seems to be exactly what is needed, as it even explicitly calls out bfcache:

The server signals that the client should remove locally cached data (the browser cache, see HTTP caching) for the origin of the response URL. Depending on the browser, this might also clear out things like pre-rendered pages, backwards-forwards cache, script caches, WebGL shader caches, or address bar suggestions.

However, note the word “might”. In my testing, sending this header does currently evict pages from bfcache in Chrome. But, there is an open spec question for whether Clear-Site-Data should clear bfcache; it mentions dropping the cache directive entirely, and it suggests that a different executionContexts directive may be more appropriate (but its browser support is even worse and it may be dropped from the spec). Firefox dropped support for the cache directive but then restored it, although in testing it seems Firefox only evicts pages from the HTTP cache and not from bfcache. Finally, Safari doesn’t seem to evict pages from either the HTTP cache or bfcache when this header is sent. 

When/if browser support for this is improved, implementing eviction of pages from browser caches could be as simple as:

add_action(
	'clear_auth_cookie',
	function () {
		header( 'Clear-Site-Data: "cache"' );
	}
);Code language: PHP (php)

Ultimately, while this header seems to be tailor-made for evicting pages from browser caches, it cannot be relied on since it is clearly not widely available in Baseline, and not even “newly available” either. There is also a Chromium bug (40233601) where sending this header can greatly delay page response times, possibly causing a logout link to take half a minute to respond. The final mark against Clear-Site-Data is that the header requires a secure context (HTTPS), so the WordPress sites still on HTTP could not evict pages from browser caches using this header (although privacy could be the last of their concerns at this point).

So to preserve privacy, another browser page cache invalidation method is currently needed.

Broadcast Channel

Going back to the aforementioned bfcache blocking reasons, one of them is “broadcastchannel-message”:

While the page was stored in back/forward cache, a BroadcastChannel connection on the page received a message to trigger a message event.

I went down a rabbit hole with this one, thinking I could leverage this blocking reason as a bfcache eviction mechanism. In my discarded implementation, all authenticated pages include a script which listens to a broadcast channel such as “auth_change”. Then, to evict authenticated pages from bfcache upon a successful logout (or logging in as another user), a script can be included on the page which simply broadcasts an arbitrary message to this same “auth_change” broadcast channel. This causes the eviction of any authenticated pages in bfcache which are listening to messages from this broadcast channel.

I was seeing very promising results with this approach, where pages were being successfully evicted from bfcache in Chromium (Chrome/Edge) and Firefox. Nevertheless, eviction was not happening in Safari, which apparently has not yet implemented this blocking reason. However, this approach fell apart once I closed DevTools.

Normally when DevTools is open I have “Disable cache” checked in the Network panel. This disables the HTTP cache, but not the bfcache. What I would see then is if I navigated around the WP Admin and then logged out in a second tab, hitting the back button in the first tab would immediately take me to the login screen. This is exactly what I wanted. However, once I closed DevTools, I started seeing pages served from the browser’s HTTP cache when navigating back/forward. Because the no-store directive is removed, this means the browser may actually serve pages from HTTP cache when navigating back/forward, even when the Cache-Control header has no-cache, must-revalidate, and max-age=0. And here, when the user logged out, they shouldn’t be served from either HTTP cache or bfcache to preserve privacy. So this was not a viable approach in the end.

Just-in-time Page Cache Invalidation

Ultimately, the most reliable solution I’ve found to invalidate pages from both bfcache and the HTTP cache is to do so just-in-time with JavaScript: When a page is restored from a browser cache and it is determined to be stale, the page contents are wiped and the page is reloaded. This approach is also referenced in the aforementioned “Update stale or sensitive data after bfcache restore” section on web.dev, although I’m accounting for pages not only restored from bfcache but also the HTTP cache.

My implementation involves the creation of a new session token when the user authenticates. This session token is served in the HTML with each authenticated page, and it is also set as a wordpress_​bfcache_​session_​{COOKIEHASH} cookie which can be read by JavaScript. When a user logs out, this session token cookie is cleared. Whenever a page is loaded, the session token in the HTML is compared with the current session token in the cookie. When they don’t match, then the page is invalidated. If a page is served from bfcache, then the pageshow event (with persisted true) is used to do this check; if a page is served from the HTTP cache, then the check is done when the script module is executed. If the user had logged out, then reloading the page will take them to the login screen which, after authenticating, will redirect them back to the page they had been on.

As this page cache invalidation mechanism depends on JavaScript, scripting must be enabled to be eligible to omit the no-store directive from the Cache-Control header. This JavaScript detection can be done when the user submits the login form.

This implementation has been made available in a new plugin: No-cache BFCache. It is available on WordPress.org and on GitHub. This is a feature plugin to implement #63636 in WordPress core.

Demos

In the following demos, I have Slow 4G network emulation enabled along with CPU throttling. This is in order to better simulate what an average user may experience when navigating, such as on a mid-range device potentially with a WordPress site on a shared host.

Demo: Navigating the WordPress Admin

After navigating from the Dashboard to the posts list table and then opening a post to edit, you can see the difference in speed navigating back and forth through the browser history:

Without bfcache

With bfcache

Demo: Navigating the WordPress Frontend

Here you can see me draft a message in a BuddyPress activity update. Then I navigate to another URL, and then I go back and forward:

Without bfcache

The drafted BuddyPress activity update is lost when navigating away from the page before submitting. The activity feed and Tweet have to be reconstructed with each back/forward navigation.

With bfcache

The drafted BuddyPress activity update is preserved when navigating away from the page without submitting. The activity feed and Tweet do not have to be reconstructed when navigating to previously visited pages via the back/forward buttons.

Appendix

WooCommerce has been serving pages with no-store even for unauthenticated users on its Cart, Checkout, and Account pages. This slowed down navigating a store, even though Woo already implements support for ensuring a cart is up-to-date via the pageshow event, as mentioned above. The lack of bfcache means not only that navigating back to the cart is much slower than it needs to be, but there can also be data loss, for example an entered coupon but accidentally not applied. In PR #58445 the sending of no-store was removed. This is slated to be part of v10.1. See before/after demo videos.

Similarly, in Jetpack certain admin screens are sending no-store which slow down back/forward navigations. Pending PR #44322 removes this and greatly speeds up navigating to/from Jetpack admin screens, as seen in the demo videos.

Sometimes the instantaneous navigations enabled by bfcache (and prerendering in Speculative Loading) can be a bit jarring, since pages load faster than expected. So the No-cache BFCache pairs well with the View Transitions plugin which now features an Admin View Transitions opt-in. This helps smooth the overall experience.


Special thanks to Kinsta for sponsoring part of my time contributing to WordPress while I’ve been between full time roles. I also discussed this bfcache project in my “From Cassette Tapes to Core Commits: Weston Ruter’s WordPress Journey” interview with Roger Williams last week.


Where I’ve shared this, if you want to boost:

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *