Fixing WordPress Cache Bloat: The Solution

I got a call from a client running a massive WooCommerce setup – thousands of products, custom post types for everything under the sun. They were hitting object cache limits constantly. Admin dashboard was dog slow, front-end queries were randomly taking forever, and their server bills were climbing because the cache kept thrashing. Total mess. They’d been told their custom queries were just “too complex” for WordPress caching, but I knew better. The root cause? Inconsistent cache keys, particularly with how WordPress handled query groups before 6.9.

For years, WordPress query caches used the ‘last changed’ timestamp as a salt. Sounds fine on paper, but on high-traffic, heavily updated sites, this meant every little post update changed that timestamp, making previous query caches unreachable. You’d query, get a cache miss, regenerate the query, save it with a *new* key, then the next update would trash that one too. It wasn’t just inefficient; it was actively hostile to object caches, filling them with dead entries that just sat there until eviction, contributing to that awful cache bloat.

My first thought, honestly, was to dig into their custom query logic and try to implement some aggressive manual cache invalidation hooks. Like, `save_post`? Clear everything related. Seemed logical, right? But that just led to a different kind of hell. We’d get race conditions, users seeing stale product data, and sometimes, the cache would actually get *more* hammered because we were clearing it for trivial updates, forcing full re-queries. It was a band-aid on a gushing wound. The real fix for consistent cache keys had to be deeper.

WordPress 6.9: Finally Tackling Consistent Cache Keys

Thankfully, WordPress 6.9 introduces significant changes that directly address this headache. Instead of salting cache keys with the ever-changing ‘last changed’ timestamp, core now stores that timestamp *alongside* the cached data. What does this mean? It means the cache key itself stays consistent. When a query is made, WordPress hits the existing cache, then checks if the ‘last changed’ time stored with the cache entry is still valid. If it’s stale, *then* it regenerates the data, updates the cached entry, and replaces the old data. Same key, fresh data. Genius, really. This change applies to all your standard query groups: comment-queries, network-queries, post-queries, site-queries, term-queries, and user-queries. You can read more about it, including specific changesets, over on the WordPress Core development blog.

Core now uses new functions: wp_cache_get_salted(), wp_cache_set_salted(), and their multiple counterparts. These are pluggable, so existing persistent caching drop-ins are compatible without modification, but you *can* optimize if you need to. Here’s how you might integrate it into your custom code if you’re directly manipulating these specific query caches (and trust me, if you’re battling cache bloat, you probably are):

<?php
/**
 * Custom function to get posts with consistent caching.
 *
 * @param array $args WP_Query arguments.
 * @return array Array of post objects.
 */
function bbioon_get_cached_posts( $args ) {
    $cache_key    = 'bbioon_custom_posts_' . md5( serialize( $args ) );
    $group        = 'post-queries'; // Use the appropriate core query group.
    $last_changed = wp_cache_get_last_changed( $group ); // Get the current last changed value for the group.

    // Check if the new salted functions are available (WP 6.9+).
    if ( function_exists( 'wp_cache_get_salted' ) ) {
        $posts = wp_cache_get_salted( $cache_key, $group, $last_changed );
    } else {
        // Fallback for older WordPress versions.
        // This is where your previous, less efficient caching might have been.
        $posts = wp_cache_get( $cache_key, $group );
        if ( false !== $posts && $posts[0] === $last_changed ) { // Simple manual check if you previously salted
             $posts = $posts[1]; // Get actual data
        } else {
            $posts = false; // Force re-query if stale or no cache
        }
    }

    if ( false === $posts ) {
        $query = new WP_Query( $args );
        $posts = $query->posts;

        // Set the cache using the new salted function or fallback.
        if ( function_exists( 'wp_cache_set_salted' ) ) {
            wp_cache_set_salted( $cache_key, $posts, $group, $last_changed, DAY_IN_SECONDS );
        } else {
            wp_cache_set( $cache_key, array( $last_changed, $posts ), $group, DAY_IN_SECONDS );
        }
    }

    return $posts;
}

// Example usage:
// $cached_posts = bbioon_get_cached_posts( array( 'post_type' => 'product', 'posts_per_page' => 10 ) );
?>

When upgrading to WordPress 6.9, you might see a temporary spike in cache misses. This is normal because the keys are changing. You might want to pre-emptively evict old, stale cache keys to ensure a clean transition. It’s a small price to pay for sanity. For a deeper dive into the technical specifics, check out the make.wordpress.org dev note on consistent cache keys for query groups in WordPress 6.9.

So, What’s the Real Takeaway Here?

  • WordPress 6.9 fundamentally changes how query group cache keys work, moving from an ever-changing salt to a consistent key with an in-memory `last changed` check.
  • This drastically reduces object cache bloat and improves performance on high-traffic sites.
  • If you’re directly manipulating core query caches, you’ll need to adapt your code, ideally using the new `wp_cache_*_salted` functions, but don’t worry, existing drop-ins are still compatible.
  • Embrace the change; it’s a huge win for performance and cache stability.

Look, this stuff gets complicated fast. If you’re tired of debugging someone else’s mess and just want your site to work, drop my team a line. We’ve probably seen it before.

Leave a Reply

Your email address will not be published. Required fields are marked *