Advanced WordPress Caching Techniques: How to Avoid a Cache Stampede

Learn how to prevent cache errors with wp-cron and other best practices.

Caching is an essential technique for optimizing the performance of a WordPress site. You’ll significantly reduce page and server load times by caching frequently accessed content and serving it from memory instead of querying the database every time.

However, caching can also lead to cache stampedes, which have the opposite effect, slowing down the site and increasing server load.

What is a cache stampede?

A cache stampede occurs when multiple processes try to regenerate identically cached content at the same time. 

For example, the code snippet below displays the three most recent posts on every page of your site. If you set the cache TTL to 15 minutes, every time the cache expires, all the processes that are rebuilding the pages will try to regenerate the same content simultaneously, resulting in a surge of SQL queries that can slow the site or even bring it to a halt.

function my_stampeding_cache() {
    $posts = wp_cache_get( 'latest_posts' );

    // $posts will be false when the cache has expired
    if ( false === $posts ) {
        // get the latest 3 posts
        // See Section 70 - Querying - for tips on a more efficient query
        $args = array(
		'post_type'              => array( 'post' ),
		'nopaging'               => true,
		'posts_per_page'         => '3',
		'no_found_rows'          => true,
		'ignore_sticky_posts'    => true,
		'order'                  => 'DESC',
		'orderby'                => 'date',
	);

	// The Query
	$query = new WP_Query( $args );

        // save the query result for exactly 15 minutes
        wp_cache_set( 'latest_posts', $query->posts, 15 * MINUTES_IN_SECONDS );
    }

    // very simple list of post IDs
    foreach ( $posts as $post ) {
        echo '<li>' . intval( $post->ID ) . '</li>';
    }

}

When the cache TTL expires as various front-end pages are rebuilt in PHP processes initiated by a URL request, each of the processes independently will see the cache is gone. As such, they will start identical and sometimes parallel SQL queries that can be wasteful, or block resources for a period of time, in an attempt to obtain the underlying data.

This can put a site into a cyclical mode where it periodically slows down as other queries have to wait. It might also start scaling to handle all the incomplete PHP processes, leading to slow page loads for site users and editors.

To avoid cache stampedes, use advanced caching techniques that ensure only one process regenerates the cache at a time.

Adding jitter to the TTL

In the context of caching, the term “jitter” is commonly used to refer to the introduction of random variations or fluctuations in a value or parameter.

Adding jitter to the time-to-live (TTL) value is a technique used to mitigate cache stampedes. 

By introducing jitter to the TTL, we introduce random variations in the expiration time of cache entries. Instead of all cache entries expiring at the exact same time, they expire at slightly different times due to the added jitter. This helps to distribute the load more evenly and reduce the likelihood of cache stampedes.

Example without jitter

Cache entry A has a TTL of 60 seconds. Cache entry B has a TTL of 60 seconds.

Both entries were created at the same time and will therefore expire at the same time.

When they expire, requests will regenerate all of the now-expired cache entries at once, which means a slower response time and a stampede on the cache servers.

Example with jitter

Cache entry A has a TTL of 60 seconds with added jitter of ±10 seconds. Cache entry B has a TTL of 60 seconds with added jitter of ±10 seconds.

Entries were created at the same time but expire at slightly different times due to the jitter.

When they expire, requests for regeneration are spread out over a range of 50 to 70 seconds.

By introducing this random variation, the requests for cache regeneration are distributed more evenly over time, reducing the chances of simultaneous regeneration attempts and mitigating cache stampedes.

Here’s a code sample to illustrate how jitter in TTL helps avoid cache stampedes:

function get_some_cached_data() {
    $key = 'cache_key';

    // Check if the transient cache entry exists
    $value = wp_cache_get( $key );

    if ( false !== $value ) {
        return $value;
    }

    // If the cache entry doesn't exist, generate a new value
    $value = generate_value();

    // Set the transient cache entry with the generated value and TTL
    $ttl = get_cache_ttl( 60 ); // TTL in seconds, with a jitter

    wp_cache_set( $key, $value, null, $ttl );

    return $value;
}

function get_cache_ttl( $ttl ) {
    // Add a jitter to the TTL
    $jitter = 0.1; // 10% jitter
    $jittered_ttl = $ttl + ( $ttl * $jitter * ( rand( 0, 1 ) ? 1 : -1 ) );

    return $jittered_ttl;
}

function generate_value() {
    // Simulate generating a new value
    sleep( 1 ); // Simulated delay in generating the value
    return 'Generated value';
}

// Example usage
$value = get_some_cached_data( $key );

Overall, adding jitter to the TTL helps to smooth out traffic patterns and alleviate the potential performance issues caused by cache stampedes.

Caching via wp-cron

One of the most effective ways to avoid cache stampedes is using wp-cron to regenerate the cache at predetermined intervals or in response to specific events. Here, only one process is responsible for rebuilding the content, ensuring there is no duplication of effort or contention for resources.

There are several strategies for cache regeneration via wp-cron, depending on the nature and frequency of updates. 

1. Scheduled regeneration

Set a regular schedule for regenerating the cache (hourly, daily, or weekly). This approach mimics the TTL mode of caching, but instead of regenerating the cache immediately after it expires, you regenerate it at predetermined intervals.

2. Event-based regeneration

Regenerate the cache in response to specific events, such as when a post changes status from published to not published, or when a new comment is added to a post. This approach ensures the cache is always up-to-date and avoids the need for periodic regeneration.

3. On-demand regeneration

Regenerate the cache on demand when a post is updated. This is useful for sites that have frequent updates, but it can slow the editing process, so use it with caution.

Your regeneration strategy depends on your site’s specific needs and usage patterns. For example, if your site has much user-generated content, consider using event-based regeneration to ensure the cache is always up-to-date. If your site has mostly static content, scheduled regeneration may be sufficient.

The code below illustrates techniques to regenerate cached data using wp-cron. This sample:

  • Schedules a task to generate a list of the most recent 25 post IDs every hour
  • Saves the result indefinitely in object cache
  • Schedules an update of the list immediately when a post is published
  • Fetches the underlying post data (usually from cache) when the data is accessed from a template
<?php
// on init, hook the function to the action
add_action( 'my_regenerate_posts_cron', 'my_regenerate_posts' );

// and schedule the first (optional, particularly if you are using categories)
if ( ! wp_next_scheduled( 'my_regenerate_posts_cron' ) ) {
	wp_schedule_event( time(), 'hourly', 'my_regenerate_posts_cron' );
}

// action to regenerate on publish (you can also hook on transition instead)
add_action( 'publish_post', 'my_reschedule_cron_for_now' );

// scheduling function, if you are using category, then you'd need to extract that from the $post argument
function my_reschedule_cron_for_now() {
	// Clear any existing hourly cron, note this needs the same args (if any) as the scheduled event if you're passing a category
	wp_clear_scheduled_hook( 'my_regenerate_posts_cron' );
	// Reschedule the hourly updates, initiating an immediate regeneration.
	wp_schedule_event( time(), 'hourly', 'my_regenerate_posts_cron' );
}

// cron task to generate posts, it could have an optional set of params eg category
// this runs under wp_cron asynchronously
function my_regenerate_posts() {
	$cache_key = 'my_cache_key';    // cache key
	$some_url = 'http://example.com/url-with-posts/'; // URL to invalidate, optional

	// Your query code here
	$args = [
		'posts_per_page' => 25,
		'fields'         => 'ids',
		'post_type'      => [ 'post' ],
		'no_found_rows'  => true,
		'order'          => 'DESC',
		'orderby'        => 'date',
	];
	$query = new WP_Query( $args );

	// save it in a transient for a long time
	wp_cache_set( $cache_key, $query->posts );

	// optional for VIP Go if you have known endpoints
	wpcom_vip_purge_edge_cache_for_url( $some_url );

}

// code that gets the posts - it does not attempt to query if there are no posts
// this would be called from your widget, or theme, or plugin code
function my_get_posts() {
	$cache_key = 'my_cache_key';    // cache key
	$posts = [];   // posts array

	// get the cached data. Return an error if there's no data
	$ids = wp_cache_get( $cache_key );

	if ( false === $posts ) {
		my_reschedule_cron_for_now();
		return $posts;
	}

	// get the underlying post data (from cache usually)
	foreach ( $ids as $post_id ) {
		$posts[] = get_post( $post_id );
	}

	return $posts;
}

This code defines several functions that work together to regenerate and fetch cached data. The my_regenerate_posts() function is the core function that generates the list of post IDs and saves them in a transient. The my_reschedule_cron_for_now() function is responsible for rescheduling the wp-cron event when a post is published, ensuring the cache is regenerated immediately.

Using WordPress VIP functions to clear the edge cache

Besides using wp-cron for cache regeneration, WordPress VIP customers can use the VIP Platform’s page cache to clear the edge cache for specific URLs when the cache is invalidated. The VIP platform automatically flushes the cache for common related urls when content is updated. This includes the post’s permalink, the homepage, taxonomy urls for related terms, and feed urls. This keeps your site content up-to-date without needing to wait for caches to expire naturally, all automatically with no further configuration.

There are cases when finer control is needed, such as for urls that are not automatically cleared, such as custom routes. Individual urls can be flushed with wpcom_vip_purge_edge_cache_for_url( $url ) and the list of automatically flushed urls can be altered using the wpcom_vip_cache_purge_{$post->post_type}_post_urls filter. For more details, please see the Cache API docs.

A caveat here: it’s only necessary when waiting for the normal cache expiration might be unsuitable and the endpoint URLs are known and finite. To control the edge cache TTL of a resource, use the max-age header.

Improving WordPress performance

Looking for other ways to improve your enterprise WordPress performance? We’ve got lots of great resources for developers who want to optimize their WordPress sites.

Get the latest content updates

Want to be notified about new content?

Leave your email address and we’ll make sure you stay updated.