Asynchronous publishing actions on VIP Go

Often when a post is published, there’s a number of actions associated with that publication. Some of those actions can take a while to perform, for example syndicating content out to sibling sites, pushing to social media, etc.

On VIP Go you can easily offload actions to be processed asynchronously on the Cron infrastructure for your site when a post is published or transitions status. Offloading actions reduces the processing time required for your publish action, which makes for a much faster and nicer publishing experience for your editors.

How to offload actions

Offloading an action on publish for asynchronous processing is as simple as changing the publish action hook to one of our async_ equivalents. The hooks we have available for asynchronous processing are:

  • async_transition_post_status, the asynchronous equivalent of transition_post_status (core hook docs)
  • async_{$old_status}_to_{$new_status}, the asynchronous equivalent of {$old_status}_to_{$new_status} (core hook docs)
  • async_{$new_status}_{$post->post_type}, the asynchronous equivalent of {$new_status}_{$post->post_type} (core hook docs)

This hypothetical code example splits off some functionality to be processed as the post is published, and some longer running functionality to be processed asynchronously:

 * Runs when a post of type `post` is published.
function my_post_publish_actions() {
	// This function performs quick actions,
	// and things which must happen immediately
add_action( 'publish_post', 'my_post_publish_actions' );

 * Runs asynchronously, when a post of type `post` is published.
function my_slower_post_publish_actions() {
	// This function performs slower actions,
	// like syndication and other longer 
	// running tasks
add_action( 'async_publish_post', 'my_slower_post_publish_actions' );

The code which powers our asynchronous offloading functionality works by scheduling an immediate Cron event to be processed on our dedicated Cron infrastructure, during which these async_* action hooks are called.


The asynchronous actions will be processed on separate infrastructure to the web requests; this means that the asynchronous action will not have access to anything which has been placed in the global context, or access files placed in the System temp directory, etc.

Controlling VIP Go Page Cache

The VIP Go page cache is one of the caching layers of VIP Go. The page cache is served from our global network of edge locations (usually from memory).
By default, your VIP Go page cache behaves as follows:

  • Logged in users bypass the VIP Go page cache entirely
  • No-cache headers are respected, but should only be used after consulting VIP, use nocache_headers()
  • Responses with an HTTP Status Code of 200, including WordPress posts, pages, archives, the homepage, etc, are cached for 30 minutes; these responses are sent with a response header of cache-control: max-age=300, must-revalidate for browser cache control purposes
  • Enqueued JS and CSS is concatenated by VIP Go and cached for 15 days (this cache is busted when resources change). Note: The HTML is cached for 30 minutes, so the references to the new JS/CSS files could be up to 30 minutes out of date.
  • Other responses are cached for 1 minute or less
  • Redirects with a 302 HTTP Status are cached for 1 minute, redirects with a 301 HTTP Status are cached for 30 minutes.
  • All WordPress REST API responses are cached for 1 minutes (more about the WordPress REST API)

Using the developer tools in your browser you can likely see some detail about your cache behaviour in the following HTTP response headers:

  • x-cache shows you whether the response was from cache (hit), not from cache (miss), or grace (see below)
  • cache-control shows the max-age of the cache for this content
  • age shows the age of any response which hit the cache

The page cache considers the whole URL, including GET parameters; e.g. ?a=1&b=1 is different from ?b=1&a=1 and both are cached separately. When purging a URL, all variants of the GET parameters are purged at the same time. This means that a post with slug hello-world will have all variants ( eg.: /hello-world?a=1&b=1 as well as /hello-world?b=1&a=1 ) purged along with the main post.

When a page or a post of any kind (built-in post or custom post type) is published, the following caches are cleared by default:

  • The single post/page URL
  • The comments feed URL for that post/page
  • The term archive URL for all terms associated with the post
  • The term archive feed URL for all terms associated with the post
  • The homepage

When a term is created, updated, or deleted, the following caches are cleared by default:

  • The term archive URL for all terms associated with the post
  • The term archive feed URL for all terms associated with the post

We provide an API to filter the URLs being queued for clearance, to clear the cache for a specific URL, or for a post or term, see below.

You may also be interested in geo-targetting your site content.

Grace Responses

Grace responses ensure that intensely trafficked resources on your site continue to be served in a timely fashion.

The grace period for a given cache object (i.e. the cached response for a particular request) is the max-age for that object, plus one minute, e.g. for a max-age of five minutes the grace period will be six minutes.

If a request is received for a cache object within the grace period but outside of the max-age of the cache, the page cache can serve a “graced” object (i.e. the previously cached response for this request) immediately, and request a freshened resource from the origin servers. Once the cache is refreshed, subsequent requests are served the fresh response until the max age is reached and the cycle starts again.

Here’s an example timeline of requests for a particular URL:

  1. request at 00:00, no cache, request is served from the origin servers and cached for subsequent resources with a max-age of 5 minutes
  2. request at 00:04, the cache is valid so the response is served from cache
  3. request at 00:05, the cache has reached max-age but is within the grace period. The response is served with a “grace” copy of the current cache, while in the background the cached copy is refreshed from the origin servers.
  4. …etc

You can identify a graced response by the X-Cache: grace HTTP response header.

One practical implication of the grace period is that your site may serve cached objects which are up to one minute over the max-age of the cache.


The VIP Go caching system does not vary for most cookies (the exceptions are listed below), this means that if you personalise content based on a cookie not in this list the response will be cached and served to the next user regardless of whether they have that cookie with the same value or not.

If a request has one of the following cookies, it will PASS cache, i.e. the request and response will be considered uncacheable:

  • A WordPress comment cookie
  • A WordPress test cookie
  • A WordPress post password cookie
  • A WordPress authentication cookie
  • A Woocommerce session cookie

If you set a cookie on the server as part of a response, that resource (e.g. that URL) will be marked as uncacheable for 120 seconds; for this reason, setting cookies on busy pages or endpoints can affect the stability of your application.

In most cases, the most straightforward way to handle the limitations above is to move user-level interactions client-side using javascript.

Cache API

Using the cache clearance API you can:

  • Set the maximum age of the cache
  • Clear the cache for a specific URL, post, or term
  • Filter the URLs being queued for clearance in a variety of ways

Set the maximum cache age

Some sample code to control cache age for feeds is below:

* Hooks the wp action to insert some cache control
* max-age headers.
* @param Object wp The WP object, passed by reference
* @return void
function wpcom_vip_cache_maxage( $wp ) {
    if ( is_feed() ) {
        // Set the max age for feeds to 5 minutes
        if ( ! is_user_logged_in() ) {
            header( 'Cache-Control: max-age=' . (5 * 60) );         
add_action( 'wp', 'wpcom_vip_cache_maxage' );

Please do not set cache age lower than 1 minute for heavily trafficked resource types. During code review we will check anywhere you are setting the maximum cache age and discuss the options with you.

Note the is_user_logged_in() check, which ensures the cache TTL headers are not set for logged in users, as this would trigger browser caching for them.

Clearing caches for post, term, or a specific URL

We have three functions you can call to clear particular caches:

wpcom_vip_purge_edge_cache_for_url( $url ) – purge the cache for one specific URL

wpcom_vip_purge_edge_cache_for_post( $post ) – purge the caches related to a post (see above, for which caches are associated with a post)

wpcom_vip_purge_edge_cache_for_term( $term ) – purge the caches related to a term (see above, for which caches are associated with a term)

Filter the URLs cleared when a post is changed

Whenever a post is published, a published post is changed, or a post is un-published, a set of URLs is assembled to be purged. By default we include most URLs you would want to clear, see the list above in “Overview”, and you can add to or remove from these URLs using the wpcom_vip_cache_purge_{$post->post_type}_post_urls filter.

Example code below:

 * Hooks the wpcom_vip_cache_purge_post_post_urls filter to 
 * also clear a custom JSON endpoint for the post URL.
 * This targets posts of post type "post" only.
 * @param array urls An array of URLs to be purged
 * @param int post_id The ID of the post for which we're purging URLs
 * @return array An array of URLs to be purged
function my_cache_purge_post_urls( $urls, $post_id ) {
    $post = get_post( $post_id );
    if ( empty( $post ) ) {
        return $urls;
    // Also purge the JSON format for the posts
    $urls[] = get_permalink( $post_id ) . '/json/';
    return $urls;
add_filter( 'wpcom_vip_cache_purge_post_post_urls', 'my_cache_purge_post_urls', 10, 2 );

Filter the URLs cleared when a term is changed

Whenever a term is created, changed, or deleted, a set of URLs is assembled to be purged. By default we include most URLs you would want to clear, see the list above in “Overview”, and you can add to or remove from these URLs using the wpcom_vip_cache_purge_{$taxonomy_name}_term_urls filter.

Example code below:

 * Hooks the wpcom_vip_cache_purge_category_term_urls filter to 
 * also clear a custom JSON endpoint for the term archive
 * @param array urls An array of URLs to be purged
 * @param int term_id The ID of the term for which we're purging URLs
 * @return array An array of URLs to be purged
function my_cache_purge_term_urls( $urls, $term_id ) {
    $term = get_term( $term_id );
    if ( is_wp_error( $term ) || empty( $term ) ) {
        return false;
    $term_link = get_term_link( $term, $taxonomy_name );
    if ( is_wp_error( $term_link ) ) {
        return false;
    if ( $term_link && is_string( $term_link ) ) {
        $this->purge_urls[] = $term_link . '/json/';
    return $urls;
add_filter( 'wpcom_vip_cache_purge_category_term_urls', 'my_cache_purge_term_urls', 10, 2 );

Varying the cached content by User Agent class

For anonymous requests you will see an X-Mobile-Class HTTP request header (accessible via $_SERVER['HTTP_X_MOBILE_CLASS']), our page cache provides buckets for the possible values here (desktop, smart, tablet, and dumb). You can conditionally alter your output for different classes of user agent by checking this header, and have the result cached correctly.

Geo targeting on VIP Go

VIP Go allows you to differentiate visitors from different countries, tailoring the content you serve to your visitors on a country by country basis, while still keeping the benefits of our fast page caching. Read our separate documentation on this geo targeting on VIP Go.

User_meta vs User_attributes

User Meta

As you read in our what we look for documentation, we don’t allow the use of user_meta. The reason for this is that is an incredibly large multisite and user_meta is all stored in one cache key. Therefore anything stored in user_meta will be loaded on every page load. Another reason is that as the user_meta grows to over 1MB it can no longer be stored in memcache.

User Attributes

User_attributes are a drop in replacement for user_meta. They perform in exactly the same way with the small exception that you cannot have multiple values for the same user_attribute meta_key. You can find the function declarations in vip/plugins/vip-do-not-include-on-wpcom/wpcom-functions.php. These are functions that imitate the behavior of user_attributes on your local dev environement. This means you can code against them without needing to have the cache or table schema that runs on

While the code you will see in that file looks just like a layer on top of user_meta on, user_attributes are stored in the cache on an individual basis and not globally. This means that requests to user_attributes by meta_key and user_id are cached individually and not grouped by user_id and loaded all at once per page load.

You can use user_attributes as a drop in replacement for user_meta. That means:

update_user_attribute( $user_id, $meta_key, $meta_value )

get_user_attribute( $user_id, $meta_key )

delete_user_attribute( $user_id, $meta_key, $meta_value = ” )

will all work as expected. Just remember, the meta_key are shared among all of so prefix them. As with user_meta you shouldn’t store large quantities of data (several KB), and lastly, since just like user_meta meta_key or meta_value on user_attributes isn’t indexed you shouldn’t query against them.

Automatic Lossless Image Compression

We have a robot – his name is optimizerbot and he will look through your themes daily to find any PNG or JPEG images that can be losslessly compressed and automatically commit the compressed version to your theme’s subversion repository.   We have found that about 80% of existing images in VIP themes can be compressed this way, saving precious bytes and providing a better user experience, especially on slower networks, like 3G.

Here is an example of what the commit message will look like:


The first column is the file name, second column is the old file size in bytes, third column is the new file size in bytes, and the last column is the % saved.

Technical details for those who are interested:

  • PNGs are being compressed using OptiPNG.
  • JPEGs are being compressed using jpegoptim.
  • The script we are using to do the compression will soon be available in the WordPress Code SVN repository.
  • We invalidate our CDN whenever static content is changed which means users will begin downloading optimized versions of these files shortly after the changes are deployed.
  • For those familiar with Trac Wiki syntax you probably will recognize the || separator. This creates a table layout in Trac, which we use internally for reviewing changes in some cases.

Creating Cache Groups with vary_cache_on_function

The Scenario

  • Batcache speeds up by reusing one response for many requests.
  • A typical URL is a resource that provides a consistent response. This is easy to cache.
  • For some URLs, the responses vary depending on the request. This is harder to cache.
  • Variants created for logged-in users are not cached at all; users always get fresh responses.
  • Variants created by inspecting other things, such as the User Agent, must be cached separately.
  • Failure to separately cache these variants results in wrong responses. This is bad.

Example Problem

Imagine you want to use Feedburner to serve your feeds. You want to redirect everyone requesting to load it from instead. You set up your blog to redirect these requests. Then your Feedburner feed breaks because when Feedburner requests your feed, you refuse to give it the data, redirecting it to along with everyone else.

So you make an exception: redirect everyone except Feedburner. You inspect the User-Agent header and if it says “feedburner” you don’t redirect; you give Feedburner the feed. This is what the Feedburner plugin accomplishes. Now your feed works as designed and everyone gets what they are supposed to get. Except sometimes they don’t.

Now you’re running into a caching problem. We cache feeds. More correctly, we cache the responses generated by feed URLs so we can serve them more rapidly for subsequent requests. A redirect is also a response and it happens to be one of our favorite kinds of responses to cache.

The problem is that you have created a variant — the conditional redirect — without telling Batcache about it. Being unaware that your feed URL has several possible responses, Batcache merrily gives everyone whichever response is created for the first request after every cache expiration interval — maybe the redirect, maybe the feed — and it doesn’t care who gets which.

Hidden Mechanism

You have to tell the cache that you are serving a variant. But it’s not that simple because what you really have to tell the cache is how you knew that you were going to serve that variant. It needs to know this because its job is to receive requests and send correct responses. And it has to do that without loading themes, plugins, or more than about 2% of WordPress. That is why Batcache is so fast.

So if your URL is going to serve variants to anyone who is not logged in, you must inform Batcache how to determine which response goes with which request. That means giving it some logic to execute on its own, without help from your theme or plugins.

Example Problem Solved

Below is the standard logic to determine whether to serve the feed or the redirect. It is a variant determiner. You just didn’t call it that before.

function is_feedburner() {
    return (bool) preg_match("/feedburner|feedvalidator/i", $_SERVER["HTTP_USER_AGENT"]);

To make this logic available to Batcache, you have to give it a copy.

function is_feedburner() {
    if ( function_exists( 'vary_cache_on_function' ) ) {
            'return (bool) preg_match("/feedburner|feedvalidator/i", $_SERVER["HTTP_USER_AGENT"]);'
    return (bool) preg_match("/feedburner|feedvalidator/i", $_SERVER["HTTP_USER_AGENT"]);

See what we did there? We put the logic in a string and sent it to Batcache. Now the cache knows how to determine which variant goes with which request.

Solution Explained

The function vary_cache_on_function tells Batcache that the URL currently being served has variants, and how to determine them. It takes one argument, a string that will eventually be passed to create_function. (Click and get acquainted if you are not already. It can be tricky.) This is how your code will be used:

$fun = create_function('', $your_code);
$value = $fun();

This is a lightweight, fast way for Batcache to execute your code without loading your theme and plugins. But it’s not for lightweights. If you are keen on PHP security you might have noticed that any number of very small typos in your code would make it possible for anyone on the internet to execute arbitrary code on your blog, which would be both embarrassing and dangerous. This is equally true of all PHP programming but if you find create_function difficult, please ask for help.

More Mechanism

Very early in the code execution, long before WordPress has had time to figure out which blog was requested or who requested it, before it can include theme or plugin scripts, Batcache swoops into action and inspects the request. With only the superglobals to guide it ($_SERVER, $_GET, $_COOKIE) the cache searches for a pre-generated response. If it finds your “vary” code, it executes that and uses the result to narrow the search.

When Batcache finds a valid response for the current request, it serves the response and halts execution. That typically takes a few milliseconds which is quite fast. When Batcache finds none, the page must be generated from scratch. Batcache becomes dormant and waits until the response is complete. Then, reborn as an output buffer handler, it swoops back in, runs your code, adds the result to the metadata of the response, caches it, and releases the response to the client who requested it.

Common example

If you want to show a specific page to a common group of countries, (for example show a cookie notice on all EU countries) the initial solution might seem to be using

wpcom_geo_add_location( 'fr' );
wpcom_geo_add_location( 'uk' );
... etc

But this will actually create a separate cache bucket for each region. greatly diminishing the cache hit % in European countries.

A better way would be to create 2 separate buckets. One for EU and one for the rest of the world like such:

if ( function_exists( 'vary_cache_on_function' ) ) {
    'if ( isset( $_SERVER["GEOIP_COUNTRY_CODE"] ) && in_array( strtolower( $_SERVER["GEOIP_COUNTRY_CODE"] ), ["be", "bg", "cz", "dk", "de", "ee", "ie", "el", "es", "fr", "gb", "hr", "it", "cy", "lv", "lt", "lu", "hu", "mt", "nl", "at", "pl", "pt", "ro", "si", "sk", "fi", "se", "uk"], true ) ) {
        return true;
    } else {
        return false;

Other Problems

We haven’t thought of all the ways Batcache can break your variants. There are lots of good reasons to create variants: conditional redirects, different markup for different user agents, serving new features to specific IPs for testing, etc. You get the idea. If you plan on varying the response for any page that will likely cached, you should use vary_cache_on_function.


  • Your code is subject to the same limitations as Batcache: it can only use core PHP functions and inspect superglobals. While it is technically possible to gain functions by including scripts within your code, this would probably defeat the purpose of keeping the cache as speedy as possible. Avoid this, and certainly don’t do it without consulting a site administrator. They can also tell you how to disable the cache when necessary.
  • Batcache executes your “vary” code only during requests for the exact URL where you called vary_cache_on_function. Please limit your use of that function to URLs that actually have variants. That will help make every page load fast.
  • Get acquainted with create_function. Use single quotes and double quotes appropriately. Test thoroughly before committing or deploying code. If you aren’t sure, ask a site administrator for help.
  • This is worth repeating: when using create_function it is extremely easy to accidentally open the door to arbitrary code execution from the internet. Test, ask others to review your code, and test again.

Batcache uses Batcache to store and serve cached versions of rendered pages. Batcache uses memcached as its storage and is aimed at preventing a flood of traffic from breaking your site. It does this by serving old pages to new users. The caching is introduced after the same page has been requested at least 4 times in 5 minutes. This reduces the demand on the web server CPU and the database. It also means some people may see a page that is up to 5 minutes old.

Who receives a cached pageview?

By default, all new users receive a cached pageview.

New users are defined as anybody who hasn’t interacted with your domain—once they’ve left a comment or logged in, their cookies will ensure they get fresh pages. People arriving from Reddit won’t notice that the comments are a minute or two behind, but they’ll appreciate your site being up.

Cache Groups

We bucket users into different cache groups depending on the type of user they are. This means that you can vary the content you serve to each of these groups without worrying about cache collisions, as long as you use our helper functions.

* Logged-in user or commenter (no cache)
* Desktop
* Smartphone
* Dumbphone
* iPad
* Tablet


Note that most URLs with query strings are automatically exempt from Batcache caching. (Query string variables tend to signal dynamic input that informs dynamic output, so we provide an uncached result every time.) This can be undesirable in many cases as popular pages linked to with query strings can significantly reduce the effectiveness of our caching setup and can affect the overall performance of your site.

These query string parameters are an exception to this, and if a URL only contains one or more of these, it will still be subject to Batcache:

  • hpt
  • eref
  • iref
  • fbid
  • om_rid
  • utm
  • utm_source
  • utm_content
  • utm_medium
  • utm_campaign
  • utm_term
  • fb_xd_bust
  • fb_xd_fragment
  • npt
  • module
  • iid
  • cid
  • icid
  • ncid
  • snapid
  • hootPostID
  • _

Note that these query-string parameters will be removed from requests prior to processing, and thus they will not be accessible during processing of requests.

Hashbang URLs are cached based on the part of the URL prior to the hashbang. Any AJAX-originated HTTP requests based on hashbang state information may be subject to caching as well.

Avoid server-side operations

Because Batcache caches fully rendered pages, per-user interactions on the server-side can be problematic. This means usage of objects/functions like $_COOKIE, setcookie, $_SERVER['HTTP_USER_AGENT'], and anything that’s unique to an individual user cannot be relied on as the values may be cached and cross-pollution can occur.

In most cases, any user-level interactions should be moved to client-side using javascript.

In some cases, we can help you set up Batcache variants if you’re limiting your interactions to a small set of distinct groups (e.g. serve different content for users depending on whether the cookie “customer-type” is set, or equals “paid” or “pending”). Please get in touch if this something you’re interested in setting up.

Additional Resources

Uncached Functions

WordPress core has a number of functions that, for various reasons, are uncached, which means that calling them will always result in an SQL query. Below, we outline some of these functions. We have a helper file with cached versions of some of the functions, which is automatically available to you.

  • get_posts()
    • Unlike WP_Query, the results of get_posts() are not cached via Advanced Post Cache used on the VIP platform.
    • Use WP_Query instead, or set 'suppress_filters' => false.
    • When using WP_Query instead of get_posts don’t forget about setting ignore_sticky_posts and no_found_rows params appropriately (both are hardcoded inside a get_posts function with value of true )
  • wp_get_recent_posts()
    • See get_posts()
  • get_children()
    • Similar to get_posts(), but also performs a no-LIMIT query among other bad things by default. Alias of break_my_site_now_please(). Do not use. Instead do a regular WP_Query and make sure that the post_parent you are looking for is not 0 or a falsey value. Also make sure to set a reasonable posts_per_page, get_children will do a -1 query by default, a maximum of 100 should be used (but a smaller value could increase performance)
  • term_exists()
    • Use wpcom_vip_term_exists() instead
  • get_page_by_title()
  • get_page_by_path()
    • Use wpcom_vip_get_page_by_path() instead
  • url_to_post_id() and url_to_postid()
    • Use wpcom_vip_url_to_postid() instead
  • count_user_posts()
    • Use wpcom_vip_count_user_posts() instead.
  • wp_old_slug_redirect()
    • Use wpcom_vip_old_slug_redirect() instead.
  • get_adjacent_post()get_previous_post()get_next_post(), previous_post_link(), next_post_link()
    • Use  wpcom_vip_get_adjacent_post() instead.
  • attachment_url_to_postid()
    • Use  wpcom_vip_attachment_url_to_postid() instead.
  • wp_oembed_get()
    • Use wpcom_vip_wp_oembed_get() instead.

Database Queries

As mentioned in the Best Practices Introduction, you should try and avoid direct database queries wherever possible and rely on WordPress API functions for fetching and manipulating data.

Of course this is not always possible, so if you need to run any direct queries here are some best practices to follow:

  • Use filters to adjust queries to your needs. Filters such as posts_where can help adjust the default queries done by WP_Query. This helps keep your code compatible with other plugins. There are numerous filters available to hook into inside /wp-includes/query.php.
  • Make sure that all your queries are protected against SQL injection by making use of $wpdb->prepare and other escaping functions like esc_sql and like_escape.
  • Try to avoid cross-table queries, especially queries which could contain huge datasets such as negating taxonomy queries like the -cat option to exclude posts of a certain category. These queries can cause a huge load on the database servers.
  • Remember that the database is not a tool box. Although you might be able to perform a lot of work on the database side, your code will scale much better by keeping database queries simple and performing necessary calculations and logic in PHP.
  • Avoid using DISTINCT, GROUP, or other query statements that cause the generation of temporary tables to deliver the results.
  • Be aware of the amount of data you are requesting. Make sure to include defensive limits.
  • When creating your own queries in your development environment, be sure to examine the query for performance issues using the EXPLAIN statement. Confirm indexes are being used.
  • Don’t JOIN the users table. This will not work on and is generally a bad idea.
  • Cache the results of queries where it makes sense.


Site speed is essential to a great web experience. employs multiple levels of caching automatically.

  • Full page cache: using the Batcache plugin, this caches full pageviews for a period of 5 minutes for all logged-out users
  • CDN / front-end caching: all scripts and stylesheets are served from our CDN. Images are served from our dedicated * domain.
  • Object cache: using memcached, this provides a persistent backend for WordPress’ built-in Object cache and is used for storing transient data and short-lived data that needs to persist across page loads.
  • Advanced Post Cache: Caches WP_Query calls to minimize database queries.

While we do our part to optimize load performance, we need your help. This page has information on how to make the most of our performance optimizations in your development work.

CDN / Front-End Caching

As noted, our CDN handles serving Javascript, CSS and theme image, while uploaded images are served from our dedicated * domain. When new versions of CDN-served files are deployed, the CDN will automatically flush its own cache and start serving the new version. This does not necessarily affect browser-side caching.

For CSS and Javascript, we automatically bust browser caches by appending a query string, ?m=[modified_time] to them in the source with a filter. For images, you will need to either change the file name or add a query string where the URL to the image is referenced.

Although not exactly caching, we concatenate both Javascript and CSS code and also minify CSS. You will not need to change the way your Javascript or CSS is written to take advantage of this optimization.

Database Queries

You should avoid direct database queries and always use the WordPress API. Many of the existing WordPress functions contain internal caching and will serve previously queried data if it’s still valid. Be wary of uncached functions, though.

For cases when you are sure that you cannot use a function that contains caching and need to do something that is resource intensive, you should cache the results using one of the options noted below.

Object cache and Transients

There are two approaches to caching within WordPress core, transients and the object cache. For a standard install, the difference is that transients are persistent and write to the options table with a timestamp whereas object cache functions are not persistent and cache on a page-by-page basis.

However, if you have persistent caching enabled on the server, then the transient functions become wrappers for the normal cache functions. On, transients act essentially identically to data stored in the object cache. Data stored in the cache will be available across multiple page loads but they can be cleared outside of your control.

This means that for both, you’ll need to check whether a value was returned from cache, and regenerate the data if it wasn’t. It can look something like this:

// Load my data from cache
$my_data = wp_cache_get( 'my-data' );
// Check if the cache actually returned something
if ( false === $my_data ) {
	$my_data = $wpdb->get_results( $query );
	wp_cache_set( 'my-data', $my_data );
// Present $my_data

Options table

Only when you’re storing a small amount of data that can’t easily be regenerated from a page request should you cache data in the options table.

Example: caching WP_Query

Here’s what a typical WP_Query loop looks like:

<?php $args = array( 'orderby' => 'comment_count', 'posts_per_page' => '1', 'ignore_sticky_posts' => 1 );
$query = new WP_Query( $args );
while ( $query->have_posts() ) : $query->the_post();
	// do stuff

Here’s how to modify that loop to cache the results of the WP_Query object:

// First, let's see if we have the data in the cache already
$query = wp_cache_get( 'ordered_comments_query' ); // the cache key is a unique identifier for this data

if( false === $query ) {
	// Looks like the cache didn't have our data
	// Let's generate the query
	$args = array( 'orderby' => 'comment_count', 'posts_per_page' => '1', 'ignore_sticky_posts' => 1 );
	$query = new WP_Query( $args );

	// Now, let's save the data to the cache
	// In this case, we're telling the cache to expire the data after 300 seconds
	wp_cache_set( 'ordered_comments_query', $query, '', 300 ); // the third parameter is $group, which can be useful if you're looking to group related cached values together

// Once we're here, the $query var will be set either from the cache or from manually generating the WP_Query object
while ( $query->have_posts() ) : $query->the_post();
	// do stuff

Other considerations

Keep in mind that even though the object cache is meant to be a persistant data store, cache entries can and do get evicted. As such, your code should fail gracefully or regenerate the data as needed.

Ideally, you wouldn’t pass an expires value and do smart cache invalidation instead. For example, hook into save_post and call wp_cache_delete with the cache key(s). That way you ensure that you only really need to regenerate the cache data when it’s changed.

There are certain cases where you would use unique cache keys, for example, say you have a gallery slider on different category pages and you’re caching the results of the query used to generate it. If you used the same key across the board, you’d end up with the same data everywhere. Instead, you can suffix the cache key with something like the category id to make sure you have unique key for each cached data set. In particularly complex cases, you can use an md5 value of the query args as the cache key.

Try and avoid cache slams when setting multiple caches by using a more random cache expiration time, using something like;

wp_cache_set( $cache_key, $data, null, mt_rand( 5 * MINUTE_IN_SECONDS, 10 * MINUTE_IN_SECONDS ) );

Caching Remote Data

The most common cause of slow page load times for our customers has been with 3rd party services and pulling in content from other sites. For 3rd party services try to late load them when possible, keywords: load without blocking, lazy load, and delay loading. For second group of common problems check out Fetching Remote Data.

Ready to get started?

Drop us a note.

No matter where you are in the planning process, we’re happy to help, and we’re actual humans here on the other side of the form. 👋 We’re here to discuss your challenges and plans, evaluate your existing resources or a potential partner, or even make some initial recommendations. And, of course, we’re here to help any time you’re in the market for some robust WordPress awesomeness.