Database queries

Direct database queries should be avoided wherever possible. Instead, it’s best to rely on WordPress API functions for fetching and manipulating data.

Of course this is not always possible, so if any direct queries need to be run 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.
  • Cache the results of queries where it makes sense.

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:

  • get_posts()
    • Unlike WP_Query, the results of get_posts() are not cached via Advanced Post Cache.
    • Use WP_Query instead, or set 'suppress_filters' => false.
      $args = array(
      	'post_type'        => 'post',
      	'posts_per_page'   => 3,
      	'suppress_filters' => false,
      );
      $query = get_posts( $args );
      
    • 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_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.

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.

Skipping Async Actions

There may be cases where you do not need to queue actions for specific post types (e.g. private post types with high volumes of activity). You can easily skip queueing async actions for them using the wpcom_async_transition_post_status_schedule_async filter.

The following snippet skips async transition actions for the all posts with the `x-transactions` post type:

add_filter( 'wpcom_async_transition_post_status_schedule_async', function( $value, $args ) {
    if ( 'x-transactions' === get_post_type( $args['post_id'] ) ) {
        $value = false;
    }
    return $value;
}, 10, 2 );

Caveats

Separate Contexts: 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 a System tmp directory during a previous request, etc.

Non-Atomic: The events are not guaranteed to fire in the order that they occur since we process them concurrently. This means that if you trash a post and then untrash it, the async events for those actions may fire out or of order or at the same time. This means that if you need to take action based on the status of a post, you should verify it using get_post( $post->ID )->post_status before doing so.

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), bypassed the cache (pass), 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

Cache control can be written into your theme, plugin, or client MU plugin. 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 the response code is >= 500, then the grace period is 15 seconds.

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.

Cookies

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
  • The VIP Go cache bypass cookie vip-go-cb (must have a value of exactly 1)

If the TTL is less than or equal to 0 AND the response status code is less than 400, OR there’s a Set-Cookie header, OR a Cache-Control header with a private value, OR a Vary header with a value of *, then that URL will be marked as uncacheable (hit-for-pass) for 10 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)

Purging can take up to 10 seconds to complete for all URLs on all VIP edge caches.

Setting individual users or requests to bypass cache

This technique allows chosen users to bypass cache even if they are not logged in, meaning that their requests will always processed by the origin servers, you can personalise the response, and the response will never be cached and served to other users. This might fit the use case for a paywall, or as part of some A/B testing functionality.

Requests with the vip-go-cb cookie set with a value of 1 (it must be a value of 1) will always PASS the VIP Go cache, meaning that these requests will never be served a cached response and the response to these requests will never be cached to be served to others.

Logged in WordPress users accessing a VIP Go application will always PASS the cache, so setting this cookie for logged in WordPress users is not necessary. If you have users logging in via some other mechanism, you can set the vip-go-cb cookie in addition to any other cookies or local storage data for some third party login system and be sure that these users will not have responses cached or see cached responses.

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.

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.