Speed up your WordPress site by saving static elements into transients. Get even better results with the help of object cache. In this tutorial you'll find out how!
Published

Every website should be cached efficiently to ensure it loads swiftly and smoothly. While a lot of website’s content is interactive, the vast majority of websites have sections or parts, such as headers or footers, that don’t change much – if at all. To ensure even faster loading times, get acquainted with WordPress Transients. In this tutorial you’ll find out how!

Fragment caching

With fragment caching you can pick a specific static element or section of your site and cache that separately. This way there’s no need to generate a certain part with PHP on an otherwise non-cached version of the page.

In this example we’ll pick the site header that has the following elements:

  • Site title
  • Search bar
  • Simple text widget

I’ve set up a demo site with the following specs:

Theme – GeneratePress Premium (GeneratePress child theme also in use) + “Prime” template, with demo content from the GeneratePress Site Library module. The front page looks like this:

Plugins – I installed some common plugins to simulate a real site, as a plain site wouldn’t give us good enough results: FiboSearch, WooCommerce and GenerateBlocks were installed with the Prime site template along with demo content.

$ wp plugin list
+-----------------------------+----------+--------+---------+
| name                        | status   | update | version |
+-----------------------------+----------+--------+---------+
| advanced-custom-fields      | active   | none   | 5.12.2  |
| contact-form-7              | active   | none   | 5.5.6.1 |
| ajax-search-for-woocommerce | active   | none   | 1.18.1  |
| generateblocks              | active   | none   | 1.4.3   |
| gp-premium                  | active   | none   | 2.1.2   |
| jetpack                     | active   | none   | 10.9.1  |
| log-http-requests           | active   | none   | 1.3.1   |
| polylang                    | active   | none   | 3.2.3   |
| redirection                 | active   | none   | 5.2.3   |
| trustpilot-reviews          | active   | none   | 2.5.901 |
| woocommerce                 | active   | none   | 6.5.1   |
| wordpress-seo               | active   | none   | 18.9    |
| bedrock-autoloader          | must-use | none   | 1.0.3   |
| register-theme-directory    | must-use | none   | 1.0.0   |
| install.php                 | dropin   | none   |         |
+-----------------------------+----------+--------+---------+

Speed testing with command line tools

Before we begin, let’s test the speed of our demo site first. The key in all speed optimizations is to measure, make changes, measure again, and analyze the results. In Seravo’s environment you can use our wp-speed-test helper command that runs 20 requests using cURL and by default bypasses HTTP cache. You can test against the cache with the –cache flag.

You can also use a for loop like this:

for x in {1..20}; do curl -s -o /dev/null -w "%{time_total}\n" -H "Pragma: no-cache" $(wp option get home); done | tee /tmp/speed-test.log && echo && echo "Average loading speed, 20 requests, bypassing HTTP cache: " && awk 'BEGIN{s=0;}{s=s+$1;}END{print s/NR;}' /tmp/speed-test.log

The loop does the following:

  • Fetches the defined home URL (wp option get home) with cURL 20 times while bypassing HTTP cache
  • Prints the results to the terminal and a temporary file
  • Counts the average and prints it to the terminal

Here are the results of the demo site, results are presented in seconds (object cache disabled):

0.339449
0.366431
0.355617
0.305701
0.367452
0.421360
0.297334
0.408036
0.440382
0.374408
0.343140
0.455011
0.347240
0.318424
0.315717
0.383347
1.212737
0.374326
0.433679
0.293023

Average loading speed, 20 requests, bypassing HTTP cache:
0.407641

Not too shabby! The results show us a pretty common average for a lightweight WooCommerce store. In order to simulate a bit slower site, let’s add a sleep function in the child theme header.php that runs for one second. You’ll see why later on.

// Sleep for one second for simulation
sleep(1);

Let’s test again using the same loop as earlier:

1.432624
1.293855
1.353330
1.384656
1.333774
1.409841
1.331775
1.368506
1.332189
1.359927
1.366213
1.356763
1.430705
1.335494
1.306732
1.305885
1.449740
1.301413
1.408697
1.328469

Average loading speed, 20 requests, bypassing HTTP cache:
1.35953

Save a part of the page into a transient

Now we can finally start tinkering with our simulated slow site. In this example we’ll be focusing on the site header, as the information it contains hardly changes. Thus there’s no need to let PHP to regenerate the HTML structure of the header over and over again. Without further ado, let’s take only that part of the page and save it in a transient.

With GeneratePress, the action generate_header will generate everything inside the <header> HTML tags. I copied the header.php from the parent theme into my child theme folder, and made the following additions around generate_header.

Please note that the following will work differently with every theme and is meant as an example only. Grab the code and modify it to suit your own needs.

    // Create a unique caching key for the transient
    $cache_key = 'advanced-caching-header-key_' . hash('crc32b', $_SERVER['REQUEST_URI']);

    // Get the header from a transient
    $output = get_transient( $cache_key );

    // If the variable output is empty, generate the header and save it in a transient
    if ( ! $output ) {

      // Sleep for one second for simulation
      sleep(1);

      // Start PHP output buffer
      ob_start();

      // Tell GeneratePress to generate the header
      do_action( 'generate_header' );

      // Get the generated header from output buffer, save it in a variable and delete current buffer
      $output = ob_get_clean();

      // Save the header in a transient with a one week expiration period
      set_transient( $cache_key, $output, WEEK_IN_SECONDS );
    }

    // Print the header
    echo $output;

With the cache key, think of the following: who do you want to show the cached content? In the example above it’s applied to everyone, but you might want to return the cached content based on a certain aspect: visitor’s country code, logged in vs. logged out users, etc. Check out the WordPress Codex for functions, hooks and filters that’ll help with targeting your site’s visitors.

Let’s see the transient list after inserting that piece of code. I’m using the search filter as the plugins I’ve installed save quite a lot of bloat into transients, and we only want to see the one transient we set above:

$ wp transient list --search=advanced-caching*
+------+-------+------------+
| name | value | expiration |
+------+-------+------------+
+------+-------+------------+

Nothing yet, as we need to load the page once so that the code we implemented gets executed.

$ curl -IL $(wp option get home);

Now we can find our transient. For the sake of this example, I chose not to include the value field here as it contains everything inside the <header> tags and thus would make this post very messy. The flag --human-readable converts expiration time to an easier format to read.

$ wp transient list --search=advanced-caching* --fields=name,expiration --human-readable
+-------------------------------+------------+
| name | expiration |
+-------------------------------+------------+
| advanced-caching-key_79d3d2d4 | 7 days |
+-------------------------------+------------+

So now the header has been generated once, and is saved in the database for a week. Let’s see if the speed has improved!

0.364501
0.395852
0.363992
0.381374
0.348991
0.400017
0.324965
0.433002
0.510654
0.320748
0.374619
0.386063
0.410854
0.384388
0.329187
0.395960
0.303866
0.303973
0.391231
0.318609

Average loading speed, 20 requests, bypassing HTTP cache:
0.372142

A little improvement, but no huge change yet. Remember that this is a demo site we’re testing – think what kind of an improvement similar actions could achieve on your real site and how they would speed it up, as those few lines of code already gave around 9% faster speed on my lightweight demo site.

Note that the one second from our sleep function is gone! It’s inside the if-statement that gets executed once and then saved in a transient, so it’s not executed on every page load.

Even more speed with object cache

Let’s enable object cache so that instead of the database, transients are saved in the RAM memory and served by Redis. This way transients will be read using RAM memory I/O instead of disk I/O, and RAM is way faster. Object cache is enabled by default on all sites in Seravo’s premium hosting and upkeep. If your site is missing it for a reason or another, you can easily install it with our helper command:

$ wp-update-object-cache -i

Alternatively you can copy the required PHP file from Seravo’s project base:

cp /usr/share/seravo/wordpress/htdocs/wp-content/object-cache.php /data/wordpress/htdocs/wp-content

After enabling object cache, flush caches and old transients:

$ wp-purge-cache

Not sure if Redis is working? It can be monitored with the following command:

$ redis-cli monitor

The response should say “OK”, and once you make a request towards the site there should be action on the screen, similar to this:

1653985085.577596 [0 127.0.0.1:56954] "GET" "wp:transient:wc_term_counts"
1653985085.577662 [0 127.0.0.1:56954] "GET" "wp:term_meta:30"
1653985085.577718 [0 127.0.0.1:56954] "SETEX" "wp:transient:wc_term_counts" "2592000" "a:1:{i:24;s:1:\"5\";}"
1653985085.577783 [0 127.0.0.1:56954] "EXISTS" "wp:product_cat_relationships:47"
1653985085.577813 [0 127.0.0.1:56954] "SET" "wp:product_cat_relationships:47" "a:1:{i:0;i:24;}"
1653985085.577859 [0 127.0.0.1:56954] "GET" "wp:product_tag_relationships:47"
1653985085.577956 [0 127.0.0.1:56954] "GET" "wp:terms:get_terms-7866e70a0e23d81c5ba2c05ac6fabeb1-0.25130100 1653985084"
1653985085.579281 [0 127.0.0.1:56954] "EXISTS" "wp:terms:get_terms-7866e70a0e23d81c5ba2c05ac6fabeb1-0.25130100 1653985084"
1653985085.579322 [0 127.0.0.1:56954] "SETEX" "wp:terms:get_terms-7866e70a0e23d81c5ba2c05ac6fabeb1-0.25130100 1653985084" "86400" "a:0:{}"
1653985085.579386 [0 127.0.0.1:56954] "EXISTS" "wp:product_tag_relationships:47"
1653985085.579416 [0 127.0.0.1:56954] "SET" "wp:product_tag_relationships:47" "a:0:{}"
1653985085.579458 [0 127.0.0.1:56954] "GET" "wp:product_shipping_class_relationships:47"
1653985085.579546 [0 127.0.0.1:56954] "GET" "wp:terms:get_terms-3f69b04eb77efcae00441065ecc3520b-0.25130100 1653985084"
1653985085.581175 [0 127.0.0.1:56954] "EXISTS" "wp:terms:get_terms-3f69b04eb77efcae00441065ecc3520b-0.25130100 1653985084"
1653985085.581227 [0 127.0.0.1:56954] "SETEX" "wp:terms:get_terms-3f69b04eb77efcae00441065ecc3520b-0.25130100 1653985084" "86400" "a:0:{}"
1653985085.581345 [0 127.0.0.1:56954] "EXISTS" "wp:product_shipping_class_relationships:47"
1653985085.581387 [0 127.0.0.1:56954] "SET" "wp:product_shipping_class_relationships:47" "a:0:{}"
1653985085.583058 [0 127.0.0.1:56954] "GET" "wp:posts:120"
1653985085.584185 [0 127.0.0.1:56954] "EXISTS" "wp:posts:120"

OK, it’s working. Time to test the speed again:

0.176452
0.179764
0.182328
0.191773
0.211399
0.183295
0.196298
0.166301
0.192860
0.208346
0.165879
0.178638
0.203015
0.168783
0.197439
0.178079
0.172654
0.174988
0.185884
0.176625

Average loading speed, 20 requests, bypassing HTTP cache:
0.18454

Now we’re talking! Finally, let’s run the speed test loop without theno-cache HTTP header, so we can see how fast the site can load in theory.

for x in {1..20}; do curl -s -o /dev/null -w "%{time_total}\n" $(wp option get home); done | tee /tmp/speed-test-http-cached.log && echo && echo "Average loading speed, 20 requests, HTTP cache: " && awk 'BEGIN{s=0;}{s=s+$1;}END{print s/NR;}' /tmp/speed-test-http-cached.log
0.019819
0.017404
0.017774
0.019797
0.017840
0.019566
0.025630
0.015618
0.017396
0.017400
0.015159
0.014875
0.016610
0.024926
0.022222
0.016180
0.018447
0.029069
0.016252
0.015288

Average loading speed, 20 requests, HTTP cache:
0.0188636

As the results tell us, it’s very crucial that your site has multiple cache layers that actually work. Setting the header into a transient on this demo site resulted in the average loading speed getting tens of milliseconds faster, which can be crucial when considering SEO rankings, for example.

Measure, make changes, measure again, analyze.

The speed optimization mantra

Caching external HTTP requests

In the beginning of this tutorial I printed out the list of plugins I have installed on the site. The plugin log-http-requests is there for a reason, as I want to see what kind of requests the site is doing towards external sources. Some examples of common external requests that we often see include:

  • Commercial plugins and themes checking licensing data
  • Social media plugins and integrations retrieving the same data continuously

When those external sources are slow – or in worst case scenario, don’t respond – depending on the plugin or theme code, they can cause the whole site to slow down or not load at all. It definitely shouldn’t be necessary to check if the license of a commercial plugin is valid every minute. Why not once an hour, or maybe just once a day?

log-http-requests has its own page in WordPress admin, but reading its database table via Adminer is way faster to gather data. Here are the top 20 requests by runtime (higher value = longer request):

Jetpack seems to be “calling home” all the time. Going through next pages we can see that Jetpack makes a lot of requests to multiple sources:

Those Jetpack requests need to be combed through with care, as they could be important and the response data changes every time. As the data changes, caching them could mean trouble for our site. Jetpack was installed to illustrate a prime example of a plugin making lots of external requests.

Setting up

For example, there’s simply no need to fetch the feed from https://planet.wordpress.org multiple times a day. Let’s save the responses of those requests into transients. There’s a feature for these actions in Seravo Plugin.

As the feature is there and “built-in”, we can set external requests to be cached for an hour (default) by setting a filter. I added the filters to the child theme’s functions.php. One for POST requests, one for GETs.

add_filter('seravo_cache_http_post_planet_wordpress_org', '__return_true', 10, 1);
add_filter('seravo_cache_http_get_planet_wordpress_org', '__return_true', 10, 1);

Testing

To test if the filter is working, we’ll create a simple PHP file that we can then run with wp eval-file.

<?php
echo "Request 1";
echo "\n";
wp_remote_get('https://planet.wordpress.org/feed/');
echo "Request 2";
echo "\n";
wp_remote_get('https://planet.wordpress.org/feed/');
echo "Request 3";
echo "\n";
wp_remote_get('https://planet.wordpress.org/feed/');
echo "Request 4";
echo "\n";
wp_remote_get('https://planet.wordpress.org/feed/');
echo "Request 5";
echo "\n";
wp_remote_get('https://planet.wordpress.org/feed/');

Save the file e.g. as cachetest.php, and run it with WP-CLI:

$ wp eval-file cachetest.php

The first couple of requests should take a little bit of time, and then the rest speed up. Run the command a few times, all five requests should go through much quicker. This means that it’s working. We can double-check by taking a look at the wp_options database table:

There’s the feed from https://planet.wordpress.org/feed saved as a transient.

Now that we know the impact that external requests may have on a site’s performance, I hope that with the help of this tutorial you’ll be able to examine your own site more thoroughly, and implement your own fragment caching. Just don’t forget the speed optimization mantra: measure, make changes, measure again, analyze.

Happy measuring!