View my basket

WordPress, WooCommerce & a high-traffic media event

A client recently asked if his website would be able to handle him being on the BBC TV programme, Dragons’ Den. Follow the changes we implemented, so we could handle the increased website traffic gracefully and without issue.

Define the task

If you look at a WooCommerce website’s front page in a page-load waterfall analyser, you can see all the requests the browser makes, in order to serve the entire page. Most of these should load very quickly because they’re cached (HTML), or sourced from a CDN such as BunnyNet. But there are also Ajax Callbacks that access the origin server to get things like WooCommerce cart fragments.

Anything that accesses the origin server is going to put a strain on that server when there’s very high traffic. The maths works out like this:

PropertyValue
Ajax callback count per-page2
PHP FPM worker thread count (max)10
Approximate Ajax request-response time500ms

If your initial HTML page-load response time is negligible, you’d be looking at 2 requests per second per worker thread. So you can handle 20 callbacks per second. But with 2 callbacks per page-load, you can realistically support about 10 page-loads per second.

My servers are high-spec bare-metal with lots of CPU resource and RAM. But if we had to support 50,000+ page views per minute… we would have to configure hundreds of PHP worker threads.

Striping-back the Ajax callbacks

The obvious thing to do was remove as many Ajax callbacks as possible, and offload all page content (apart from the cart page and checkout page) to an HTML CDN.

So that’s what we did.

Ajax callbacks are initiated by JavaScript code running in the browser. As long as the JavaScript code was enqueued using wp_enqueue_script(), we could just add a snippet to dequeue all the scripts that were making Ajax callbacks.

/**
 * When you change HIGH_TRAFFIC_MODE, be sure to
 * flush the caches properly (local and CDN).
 */
const HIGH_TRAFFIC_MODE = true;

function maybe_dequeue_scripts() {
	$dequeue_script_handles = array();

	if ( HIGH_TRAFFIC_MODE ) {
		// Dequeue the Woo Cart Fragments code,
		// except on the cart & checkout pages.
		if ( function_exists( 'WC' ) && ! is_cart() && ! is_checkout() ) {
			$dequeue_script_handles[] = 'wc-cart-fragments';
		}

		// Dequeue Independent Analytics assets (temporarilty)
		$dequeue_script_handles[] = 'iawp-javascript';
		$dequeue_script_handles[] = 'iawp-layout-javascript';

		// Dequeue more scripts here...
		// $dequeue_script_handles[] = 'your-script-handle';
		// ...
	}

	foreach ( $dequeue_script_handles as $handle ) {
		wp_dequeue_script( $handle );
		wp_deregister_script( $handle );
	}
}
add_action( 'wp_head', 'maybe_dequeue_scripts', PHP_INT_MAX );
add_action( 'wp_enqueue_scripts', 'maybe_dequeue_scripts', PHP_INT_MAX );
add_action( 'wp_footer', 'maybe_dequeue_scripts', 5 );

With this snippet in place, we flushed all the caches and tested in GTmetrix. That was it – zero Ajax callbacks on the front page, shop page and main product pages. A big step forward. Of course, the Cart Item count badge on the Mini Cart no longer worked, but we just replaced that with a button to link directly to the standard WooCommerce cart page.

Offloading the HTML

Request-response for cached page HTML content on my servers is typically between 50ms and 80ms. So if we assume a worst-case scenario of 100ms, each PHP worker could serve 10 pages per second. My server could handle this traffic spike, because we could allocate 30 PHP workers easily enough and serve 300 HTML pages per second (network connection permitting). But it still felt like a pinch-point, so I wanted to see if we could offload most of the HTML content to a CDN too.

The site has used BunnyNet CDN for a long time – it’s robust and never causes a problem. But I figured Cloudflare would be the natural choice for offloading the HTML pages. We wanted to use their Waiting Room functionality too – in case the traffic levels were higher than predicted.

The tricky bit was sorting out the Cloudflare caching. My instinct was to not cache everything by default… only caching things that matched the rules. That approach kept failing though – mostly because of cookies set by WordPress, and the multi-currency functionality. So the trick is to cache everything as the top rule, then exclude content from the cache, like so:

  1. Cache Everything
  2. Never cache the Woo cart+checkout pages, or visitors outside the UK (GB). This is so we only cache HTML pages for a single currency, “GBP”:
    (http.request.uri.path matches r"^/(cart|basket|checkout|my-account|wishlist)/")
    or
    (http.request.uri.query contains "add-to-cart=") or (ip.src.country ne "GB")
  3. Never cache HTML content for logged-in users, where cookie contains “wordpress_logged_in”

I confirmed this using GTmetrix again, checking the response headers would never return “DYBNAMIC” for the key pages. The CF Cache Status should always be “HIT” or “MISS”.

The big day

With the bulk of the HTML offloaded to Cloudflare, the JS, CSS & images offloaded to Bunny Net, and the Ajax callbacks stripped back, it was time for the event.

Rob and Jo from Active Hands, on the Dragons' Den TV show
Rob and Jo from Active Hands, on the Dragons’ Den TV show

Everything went super smoothly. Cloudflare took the brunt of the traffic spike, leaving the origin server to handle cart page & checkout page interactions. Because of things like catch-up TV, the spike was not as dramatic as we expected. We saw a lot of page-views, but spread out over a busy half-hour, then tailing-off throughout the evening.

The biggest win was temporarily removing the Ajax callbacks from across the site. Offloading the HTML to Cloudflare was a pain to set up, but was definitely worth getting right.

I’ll be taking some of the techniques and tools developed for this project, and folding them into my hosting provision for other clients to use.

Leave a comment