How to Make WordPress Page Cache Plugins Fly With Nginx

#

Anyone who’s trying to improve the performance of their WordPress site eventually turns to caching. There are a ton of WordPress page caching plugins available, but limiting yourself to cache plugins alone means leaving significant performance improvements on the table.

If you’ve been reading the Delicious Brains blog for any amount of time, you’ll likely know that I’m a massive advocate of Nginx FastCGI Caching. Not only is it the caching method of choice in Hosting WordPress Yourself, but it’s been the subject of many other blog posts. Most recently Page Caching: Varnish Vs Nginx FastCGI Cache 2018 Update where I benchmarked popular WordPress caching solutions. One thing is clear, Nginx FastCGI Caching is blazingly fast and relatively easy to configure. However, it does have its quirks, namely, the difficulty with invalidating the cache.

Nginx FastCGI Caching works great when both Nginx and PHP run under the same system user. This is because the cached files are writable by PHP, meaning you can purge the cache directly from PHP, which is particularly important when publishing or updating a post in WordPress. However, Nginx will always create the cache files as the user it’s running under and it’s impossible to configure the owner or group. Similarly, all cached files are created with 0600 or 0700 permissions for files and folders respectively and this cannot be customized. This means that when Nginx and PHP run as separate system users the wheels quickly fall off as you can no longer invalidate the cache from PHP.

As we’re unable to customize the file permissions in Nginx, the solution is to have PHP create the cache files instead. This brings us to WordPress caching plugins. However, if you look at the results from our last set of benchmarks, you’ll see that Simple Cache (a WordPress caching plugin) was no match for the performance offered by Nginx FastCGI Caching.

Requests per second. Winner: Nginx FastCGI Cache

Why is that? Let’s dig into how page caching plugins work.

How WordPress Page Caching Plugins Work

By far the biggest bottleneck in WordPress is connecting to the database server and querying for the data required to respond to the current request. This is done on every single request, regardless of whether the data has changed between requests. This is extremely inefficient.

WordPress page caching plugins alleviate this issue by generating a static HTML version of the request, which is usually saved to the filesystem. Subsequent requests serve the static HTML file, instead of querying the database and re-rendering the page. To do this, page caching plugins make use of the advanced-cache.php drop-in, which is executed before WordPress is loaded (if the WP_CACHE constant is true). WordPress core does this in wp-settings.php:

if ( WP_CACHE && apply_filters( 'enable_loading_advanced_cache_dropin', true ) ) {
    // For an advanced caching plugin to use. Uses a static drop-in because you would only want one.
    WP_DEBUG ? include( WP_CONTENT_DIR . '/advanced-cache.php' ) : @include( WP_CONTENT_DIR . '/advanced-cache.php' );

    // Re-initialize any hooks added manually by advanced-cache.php
    if ( $wp_filter ) {
        $wp_filter = WP_Hook::build_preinitialized_hooks( $wp_filter );
    }
}

Most caching plugins have a lot of additional logic going on in advanced-cache.php but it usually boils down to a simple file_exists check, which checks for the existence of a static HTML file. If the file exists, its contents are read and written to the output buffer. The script is then exited, which prevents WordPress from loading. It’s worth noting that anything hooked into the shutdown hook will still be executed.

A very simple example might look like this:

if ( @file_exists( $path ) && @is_readable( $path ) ) {
    @readfile( $path );

    exit;
}

Why Caching Plugins Perform Poorly in Nginx

Although WordPress page caching plugins prevent WordPress from loading and significantly improve performance, they don’t quite keep up with Nginx FastCGI Caching. Why is that? Quite simply, it’s because each request is being processed by PHP, which takes far more CPU cycles than just letting Nginx handle the request.

If you remember the good old days when Apache was prevalent, most caching plugins would recommend adding a mod_rewrite rule to your .htaccess file. This would allow Apache to serve the request without ever hitting PHP. This was possible because Apache allowed configurations to be overwritten at runtime using .htaccess files. Nginx, however, doesn’t support such conveniences.

If we could get Nginx to perform a similar file_exists check like in PHP, we could likely improve the performance of WordPress caching plugins.

Making Simple Cache Fly

As it turns out Nginx does in fact perform a similar file_exists check on every single request it handles. Most configurations have the following block.

location / {
    try_files $uri $uri/ /index.php?$args;
}

This essentially tells Nginx to serve the requested file if it exists, otherwise, perform an internal redirect to index.php. If we add our cache directory to this block, we can instruct Nginx to serve the cached HTML file directly without ever hitting PHP.

Let’s take Simple Cache as an example, which stores its cache files like so:

// Request: domain.com/
wp-content/cache/simple-cache/domain.com/index.html
// Request: domain.com/hello-world/
wp-content/cache/simple-cache/domain.com/hello-world/index.html

With that in mind, we can update our try_files directive like so:

location / {
    try_files "/wp-content/cache/simple-cache/${http_host}${request_uri}index.html" $uri $uri/ /index.php?$args;
}

After reloading Nginx, any cached pages generated by Simple Cache will be served directly from Nginx without ever hitting PHP. Requests which haven’t been cached will continue to be handled by WordPress, which will generate the cache for subsequent requests.

I should mention that I’ve disabled the ‘Enable Compression’ option in Simple Cache, because Nginx automatically compresses HTML files before sending them to the browser. This means generating the cache via PHP has slightly less overhead because it’s not having to GZIP the contents of the output buffer.

Benchmarks

It’s time to benchmark the results! As with the last set of benchmarks I performed, I’ll be using ApacheBench.

ab -n 10000 -c 100 https://domain.com/

This simulates 10,000 requests, with a concurrency of 100 requests. Meaning, ApacheBench will send a total of 10,000 requests in batches of 100 at a time. Each test will be performed a total of 10 times and the average used for comparison.

The server stack looks like this (with each site running on HTTPS):

  • DigitalOcean 1GB ($5/mo)
  • Ubuntu 18.04
  • PHP 7.3
  • Nginx 1.15.6
  • MySQL 5.7.24
  • WordPress 5, Twenty Nineteen

Requests Per Second

Let’s start with requests per second, which is the number of concurrent users your server can handle (higher is better). Unexpectedly, Simple Cache + Nginx Try Files marginally beats Nginx FastCGI Caching.

Requests Per Second

Average Response Time (ms)

The average response time is the total time it takes for a request to complete (lower is better). This is the average across all 10,000 requests, which simulates the response time when the server is under load. As above, Simple Cache + Nginx Try Files is slightly quicker.

Average Response Time

Final Thoughts

I have to admit, I’m surprised by the results. I expected Simple Cache + Nginx Try Files to perform much better, but I never expected it to outperform Nginx’s native FastCGI Caching. I can only conclude that there must be additional overhead due to Nginx determining if a cache entry is still valid. Whereas try_files simply checks for the existence of a file.

In reality, the difference between Nginx FastCGI Caching and Simple Cache + Nginx Try Files is negligible and both caching solutions are excellent options (depending on which users Nginx and PHP run under). However, going forward I think I’ll be using Simple Cache + Nginx Try Files because it provides much more flexibility when it comes to purging the cache.

Have you performed a similar optimization using Nginx and page caching plugins? Let us know in the comments below.

https://twitter.com/dliciousbrains/status/1075414720037957632

About the Author

Ashley Rich

Ashley is a PHP and JavaScript developer with a fondness for hosting, server performance and security. Before joining Delicious Brains, Ashley served in the Royal Air Force as an ICT Technician.