HTTP/2 Push WordPress Assets to First-Time Visitors

With HTTP/2 push you can effectively send a web page’s assets to the client before the client even knows about them. Here’s how you can HTTP/2 push WordPress assets to your first-time visitors.

One of the great features of HTTP/2 is how your web server can push assets to the web browser, without the browser having to make separate requests for those assets. Since the HTTP/2 connection is multiplexed, this will basically have the same effect as if your assets where in-lined in the HTML document itself. – Only that when you update an asset, the browser just need to download the changed asset, instead of everything all over.

The speed gain with HTTP/2 push for WordPress

The speed gain from this is really noticeable on a low bandwidth and/or high latency connection. E.g. if the visitor is on a bad mobile connection or from the other side of the planet. The server for this website is in Europe, but when doing testing from Australia, the waterfall will show that the CSS and JS files are actually delivered to the client before the HTML download is finished. That saves a lot of round-trips and can cut the load time from 6.5 seconds to 1.5 seconds. That is very, very noticeable if you’re a regular human user.

Using HTTP/2 push only for new visitors

So this is a great feature for new visitors to your site, but what about recurring visitors or those who click and visit a second or third page? They will now have all the assets in their cache: wouldn’t it be a waste of bandwidth to resend all those assets over again?

Yes, it is a waste of resources to re-push all assets on requests after the visitor has viewed a page on your site for the first time and stored the assets in their cache. What if there was a way to know if a visitor had visited before … like a cookie! If you use some sort of analytics software, like Google Analytics, Matomo (formerly Piwik) or just about anything else, you are probably already setting some long-lasting cookies. This means we don’t have to set a separate cookie to track our visitors: any set cookie will do. If they have a cookie, they have already been sent the assets. If they don’t have a cookie, well, then we’ll push all the assets over to them.

How we HTTP/2 push WordPress assets

The web server (e.g. Nginx, Apache) is responsible for talking to the visitors’ browsers, and this includes doing the HTTP/2 push. However, it doesn’t know which assets to push without being told. This has to be done by the underlying application, which in our case is WordPress.

This is done by making WordPress send HTTP headers like these:

Link: </style.css>; rel=preload; as=style
Link: </moar-styles.css>; rel=preload; as=style
Link: </cool-script.js>; rel=preload; as=script

But not even WordPress will know which assets are included on the page as early as it is when it is time to send the HTTP headers. There will probably be CSS and JS files that are enqueued by plugins or logic further down the content generation.

What we can do, is to let a page be generated, and collect the URLs to all enqueued assets when they are handled – together with their dependencies. Then, right before it is time to stop execution – during the `shutdown` action, we can save those asset URLs to a transient. The next time the same page is requested by a visitor, we know which assets are included on the page, and we can tell the web server to push those files – as long as the visitor doesn’t have a cookie, that is.

The WordPress implementation

Here is my current implementation of this. The license is GPLv2, so feel free to use it, modify it, turn it into a plugin or whatever else the license allows you to. The easiest way to use this, is to put the code in a file as a WordPress mu-plugin.

<?php
/**
 * HTTP/2 server push WordPress assets.
 *
 * @package BJ\AssetsPusher
 * @author bjornjohansen
 * @version 0.1.0
 * @license https://www.gnu.org/licenses/old-licenses/gpl-2.0.html  GNU General Public License version 2 (GPLv2)
 */

// phpcs:disable WordPress.VIP.RestrictedVariables.cache_constraints___COOKIE -- Because it’s only used to check if it is set.

namespace BJ;

/**
 * Class for handling asset pushing.
 */
class AssetsPusher {

	/**
	 * The stack of local asset URLs.
	 *
	 * @var string[] Array with URLs.
	 */
	private $_stack = [];

	/**
	 * The base URL: scheme and hostname.
	 *
	 * @var string The base URL.
	 */
	private $_base_url = '';

	/**
	 * The length of the base URL.
	 *
	 * @var integer The length of the base URL.
	 */
	private $_base_url_len = 0;

	/**
	 * Get an instance.
	 *
	 * @return AssetsPusher
	 */
	public static function instance() {
		static $instance = null;
		if ( is_null( $instance ) ) {
			$instance = new AssetsPusher();
		}
		return $instance;
	}

	/**
	 * No outside constructions.
	 */
	private function __construct() {
		$this->_stack = [];

		$home_url_parsed     = wp_parse_url( home_url( '/' ) );
		$this->_base_url     = $home_url_parsed['scheme'] . '://' . $home_url_parsed['host'];
		$this->_base_url_len = strlen( $this->_base_url );
	}

	/**
	 * Use the script_loader_src filters to add the enqueued asset to our stack.
	 *
	 * @param string $src    The source URL of the enqueued asset.
	 * @param string $handle The asset's registered handle.
	 * @return string The source URL of the enqueued asset.
	 */
	public static function script_loader( $src, $handle ) {

		$assets_pusher = AssetsPusher::instance();
		$assets_pusher->add( $src, 'script' );

		return $src;
	}

	/**
	 * Use the style_loader_src filters to add the enqueued asset to our stack.
	 *
	 * @param string $src    The source URL of the enqueued asset.
	 * @param string $handle The asset's registered handle.
	 * @return string The source URL of the enqueued asset.
	 */
	public static function style_loader( $src, $handle ) {

		$assets_pusher = AssetsPusher::instance();
		$assets_pusher->add( $src, 'style' );

		return $src;
	}

	/**
	 * Add the asset src to our stack if it is a local URL.
	 *
	 * @param string $src  Asset URL.
	 * @param string $type Asset type.
	 */
	public function add( $src, $type ) {
		if ( substr( $src, 0, $this->_base_url_len ) === $this->_base_url ) {
			$src = substr( $src, $this->_base_url_len );

			if ( ! isset( $this->_stack[ $type ] ) || ! is_array( $this->_stack[ $type ] ) ) {
				$this->_stack[ $type ] = [];
			}

			if ( ! in_array( $src, $this->_stack[ $type ], true ) ) {
				$this->_stack[ $type ][] = $src;
			}
		}
	}

	/**
	 * Create the transient key for this request.
	 *
	 * @return string|false The transient key. False if it could not be created for this request.
	 */
	private function get_transient_key() {
		if ( ! isset( $_SERVER['REQUEST_URI'] ) ) { // WPCS: input var ok.
			return false;
		}

		return 'assets-' . md5( $_SERVER['REQUEST_URI'] ); // WPCS: sanitization ok, input var ok.
	}

	/**
	 * Save all the enqueued local asset URLs.
	 */
	public function save_assets() {

		// We won’t push assets to logged in users, as they likely have the assets cached already,
		// so there’s no need to save the assets for logged in users (which are likely to be a longer stack
		// than for first time visitors).
		if ( is_user_logged_in() ) {
			return;
		}

		if ( count( $this->_stack ) ) {
			$transient_key = $this->get_transient_key();

			// Not a regular HTTP request.
			if ( ! $transient_key ) {
				return;
			}

			// We don’t need to re-save this on every request.
			// We’ll only re-save the asset list at max every 10 minutes.
			$save_transient    = true;
			$existing_obj_json = get_transient( $transient_key );
			if ( false !== $existing_obj_json ) {
				$existing_obj = json_decode( $existing_obj_json );
				if ( isset( $existing_obj->created ) && time() - 600 < $existing_obj->created ) {
					$save_transient = false;
				}
			}

			if ( $transient_key && $save_transient ) {
				$obj          = new \stdClass();
				$obj->created = time();
				$obj->assets  = $this->_stack;
				set_transient( $transient_key, wp_json_encode( $obj ), 86400 );
			}
		}
	}

	/**
	 * Get all the stored asset URLs for this request.
	 *
	 * @return string[] An array of all the local URLs.
	 */
	public function get_assets() {
		$assets = [];

		$transient_key = $this->get_transient_key();
		if ( $transient_key ) {

			$obj_json = get_transient( $transient_key );
			if ( false !== $obj_json ) {
				$obj = json_decode( $obj_json );
				if ( isset( $obj->assets ) ) {
					$assets = (array) $obj->assets;
				}
			}
		}

		return $assets;
	}

	/**
	 * Send the push headers.
	 */
	public function send_headers() {
		$assets = $this->get_assets();
		foreach ( $assets as $type => $urls ) {
			foreach ( $urls as $url ) {
				$header = sprintf( 'Link: <%s>; rel=preload; as=%s', esc_url( $url ), $type );
				header( $header, false );
			}
		}
	}
}

/*
 * Hook our loader into the script and style loaders. They will take care of enqueing dependencies for us,
 * and filtering out inline stuff, generating RTL src URLs and whatnot.
 */
add_filter( 'script_loader_src', [ '\BJ\AssetsPusher', 'script_loader' ], 99, 2 );
add_filter( 'style_loader_src', [ '\BJ\AssetsPusher', 'style_loader' ], 99, 2 );

/**
 * Store the local asset URLs for this request in a transient.
 */
add_action(
	'shutdown', function() {
		$assets_pusher = AssetsPusher::instance();
		$assets_pusher->save_assets();
	}
);

/**
 * Send out the push headers for our assets.
 */
add_action(
	'send_headers', function( & $wp ) {

		// If the user didn’t have any cookies, the user likely don’t have any assets cached either.
		if ( empty( $_COOKIE ) ) { // WPCS: input var ok.
			$assets_pusher = AssetsPusher::instance();
			$assets_pusher->send_headers();

			// Flush the output buffer to trigger the web server’s PUSH mechanism ASAP.
			flush();
		}

	}, 10, 1
);

One part that I’m not sure of: The cache size

My implementation above will record the assets per individual page and cache that result. There are a few things I’m not sure of:

  • Depending on how many pages and posts you have, the asset URL cache could grow pretty big. If you have thousands of pages/posts, the cache can grow into having thousands of entries.
  • The entries might be pretty much the same. Most pages/posts will likely embed the same CSS and JS files, so we’re creating extra cache entries for no reason. The cache would also perform better if we just had one entry.
  • Maybe we could just scan the front page and cache those assets? Or limit ourselves to one cache entry per post type + one for archives?

Verifying that the WordPress assets are pushed

Debugging HTTP/2 push can be a bit hard. Especially since we are conditionally pushing. Luckily the Google Chrome Developer Tools can at least tell us if a resource was pushed. Go to the “Network” tab and make sure the “Initiator” column is showing. If a resource was pushed, it will be indicated with “Push” as the initiator.

If a resource was pushed, it will be indicated in the “Initiator” column.

Server support for HTTP/2 push

At the time of writing, most popular web servers support HTTP/2, but they don’t necessarily support the push feature. The Nginx community edition doesn’t support HTTP/2 server push. You need the commercial Nginx Plus version to do that. I do believe Apache supports HTTP/2 server push via the mod_h2 module, but I’m not 100% sure on that and i have never tested it. Since HTTP/2 was released in 2015, I’ve done my HTTP/2 testing on H2O – a really fast web server with full HTTP/2 support. If your web server doesn’t support HTTP/2 push, you can use e.g. CloudFlare which is a WAF/CDN/web-cache and more – and has an excellent free starter plan.

13 Comments

  1. Always pushing the assets isn’t a problem as long as the assets are truly kept tiny. Storing cookies on the device is troublesome because of EU privacy rules, so you shouldn’t make that part of your solution.

    You can minify, combine, and HTTP/2 push styles and script assets with the Merge+Minify+Refresh plugin.

    1. There is no problem with storing cookies in regards to EU privacy rules. If you use them to track an individual, there’s an issue yes, but nothing’s wrong with using cookies in itself. Also, we’re not introducing or setting any new cookies here. We’re just piggybacking on the cookies that are already set. E.g. by analytics, after posting a comment, or after logging in.

      Concatenating assets is under HTTP/2 considered an anti-pattern, which should be avoided: After doing a change in one of them, the visitors will have to re-download all of them. There are certain advanced use-cases where some concatenation can be beneficial due to better compression rates, but that’s way out of scope of this comment :-)

    1. Because we want to transfer the assets as efficiently as possible to the client (and the browser cache) in the first place.

      Then, if we need to invalidate an asset, we can just invalidate a small piece instead of invalidating everything — as is the case if we concatenate JS/CSS or use sprites.

  2. Tried the mu solution without success. Can’t see any issues in the plugin setup and have created MU’s before but it does not server push it. H2 installed on the Apache server. hmm… any suggestion?

    1. Check that the “Link” headers actually are sent from WordPress. If not, something’s wrong with your mu-plugin. If they are, something’s wrong with your Apache.

Comments are closed.