Skip to content
System nominalUTC --:--:--Articles 041
MODULE 03 Lesson 07 / 09 30 MIN

Caching layers

The Object Cache and What It Holds

What is already in the object cache when your template runs, which code paths put it there, and how to prove a persistent backend is actually attached.

A developer installs Redis, opens the object cache statistics, and sees a hit rate in the nineties. They conclude the site is now cached. The next question, which the hit rate does not answer, is what those hits contain. Some of them are the entries that saved the database work they were hoping to save. Others are single scalars that cost less to recompute than to fetch over a socket, and a few are entries that were written earlier in the same request and will be gone the moment it ends.

You cannot reason about the object cache from a dashboard, because the dashboard counts reads and writes and knows nothing about what is worth storing. You can reason about it from $wp_object_cache, which is a plain PHP object holding a nested array of groups and keys. This lesson reads that array at the moment your template code starts running, follows a few entries back to the code that wrote them, and then measures the difference between the default backend and a persistent one.

All the numbers below come from a lab install: WordPress 7.0.7, PHP 8.5.6, MySQL 9.6.0, 300 published posts with one meta key and one category each. The persistent backend is a file-based drop-in written for the measurement, not Redis. A file store and Redis have different latency, so treat the query counts as the result and the timings as context.

By the time your template runs, five groups are already populated

Dumping $wp_object_cache->cache at the first hook your own code runs on is a sobering exercise. On the lab install, before the main query and before any plugin does anything, the cache holds this:

options             4 keys   alloptions, notoptions, two theme-file transients
translation_files   4 keys
site-options        1 key
themes              1 key
default             1 key

Each group has a different author. alloptions is written by wp_load_alloptions() during wp-settings.php, and it holds every autoloaded option as one array. notoptions starts empty and grows as WordPress discovers option names that do not exist, so a missing option is a fact the request remembers. The theme and translation entries come from wp_get_theme() and the translation loader reading files from disk. None of this is your code, all of it happens on every request, and on the default backend every one of those writes is thrown away when the response ends.

The entries your templates care about arrive later, and they arrive in groups named for the thing they hold. posts holds post objects and their metadata, terms holds term objects and term meta, users holds user objects and capabilities, post-queries holds the result of a WP_Query. The query cache stores the matching IDs and the found-rows count, not the rendered HTML, and it primes the other groups from that list.

Then there is the fourth kind, which is the one that turns into a bug. Core and plugins write entries whose meaning depends on the current request: a nonce lookup, a value derived from the logged-in user, a flag set by a shortcode so a second call can tell it already ran. With the default backend those entries die with the process and can never be wrong. Attach a persistent backend and they outlive the request that wrote them, which is safe only while the key names the context.

The default backend is an array, and the array dies with the process

WP_Object_Cache stores values in $this->cache[ $group ][ $key ], which is an in-process array. A read is an array lookup; a miss is not a database query, because the default backend has no other storage to fall back to. Two PHP workers serving the same URL share nothing, and the same worker serving two requests shares nothing either, because the array is created during wp_start_object_cache() and destroyed at the end of the request.

That is easy to state and easy to disbelieve. Here is the measurement. Request A writes a key into a group of its own:

wp_cache_set( 'm3_marker', 'written at ' . microtime( true ), 'm3lab', 600 );

Request B reads it. On the default backend, wp_cache_get() returns false and sets $found to false. With a persistent drop-in in place, the same read returns the string request A wrote, and it still does so after the PHP process that wrote it has exited. The value survived the process, which is the entire difference between the two backends.

Two consequences follow, and both matter for the rest of this module. First, a persistent cache changes the shape of your traffic, not only its speed: the second request for a URL does almost no database work, and the first request after a flush does all of it. Second, any cache bug you have in the code is invisible on the default backend, because nothing lives long enough to go stale. Most sites discover their invalidation bugs on the day they install Redis.

You can find out from inside WordPress whether a persistent cache is running

Do not infer this from a plugin's settings screen, and do not infer it from a performance change. Ask the function that core itself asks:

if ( wp_using_ext_object_cache() ) {
    // WordPress loaded wp-content/object-cache.php and it declared wp_cache_init().
} else {
    // The per-request array cache.
}

wp_using_ext_object_cache() toggles and returns the $_wp_using_ext_object_cache global. Core sets it to true in wp_start_object_cache(), and it sets it for one reason: the drop-in file existed and defined wp_cache_init(). It says nothing about whether the backend is reachable or fast, so treat it as the first question and not the last one.

The check worth adding to a staging environment prints the class of the backend next to the boolean, because a drop-in that fails to connect can leave the site on the array cache without anybody noticing:

printf(
    "ext: %s, backend: %s\n",
    wp_using_ext_object_cache() ? 'true' : 'false',
    get_class( $wp_object_cache )
);

On the lab install before the drop-in, that prints ext: false, backend: WP_Object_Cache. After it, ext: true, backend: M3_Disk_Object_Cache. If you build the drop-in yourself, one detail will cost you an afternoon: a drop-in is loaded instead of wp-includes/cache.php, so it has to define the wp_cache_* wrapper functions and not only a class. A drop-in with only a class loads successfully, and then the first wp_cache_get() in core is a fatal error.

A persistent cache turns cold starts into events

Once entries survive, the interesting measurement is what happens when they do not. On the warm path the lab install runs a twelve-post query in zero queries and reads an option in zero, because both answers are in the backend. Then wp_cache_flush() runs, and the next identical query costs seven.

Seven is not special. What matters is the shape: one call put every group on the site back to its cold state, and the cost of that is paid by whichever requests arrive next, not by the call itself. If the flush happens during a deploy, the traffic that pays is whatever arrives after the deploy, which is usually the heaviest traffic of the day. Two habits follow. Warm what matters after a flush rather than waiting for a visitor to do it, and do not put a flush on a code path that runs more than once in a blue moon.

The blunt-instrument problem has a second half, which is that the API gives you almost no alternative. wp_cache_flush_group( $group ) exists, and before you call it you have to check wp_cache_supports( 'flush_group' ), because a backend that cannot flush one group has to be flushed entirely. The lab drop-in, which deliberately implements no group flush, answers both questions this way:

backend=M3_Disk_Object_Cache ext=true
supports flush_group: no, flush_runtime: no

With that backend, wp_cache_flush_group( 'options' ) calls _doing_it_wrong() and returns false. It does not fall back to flushing everything, and it does not delete the group, so the honest reading is that the function silently did nothing and your stale option is still there. Check the return value the first time you use it on a new host, because "returns false" and "returns true" look identical in a code review.

Groups are invalidated by moving one counter

Nothing scans a cache group to invalidate it. Each group has a companion entry called last_changed, and any code that writes to the group is expected to move it. wp_cache_get_last_changed( 'posts' ) reads the marker and creates it if it is missing, and wp_cache_set_last_changed( 'posts' ) replaces it with microtime(). Keys built inside the group mix the marker in, so the moment it changes, every key derived from it stops matching. The old entries become unreachable and expire on their own.

The trade is precision for a guarantee that only fails in one direction. A cache key derived from the marker can never serve data older than the last write, and it pays for that by throwing away more than the write invalidated. You can watch the cost in one line of measurement on the lab install. Cache a query, then call update_post_meta() once, then ask for the same query again:

first WP_Query, identical args        6 queries
second WP_Query, identical args       0 queries
update_post_meta() on one post        3 queries
same WP_Query after the write         3 queries

One meta row changed and the query had to run again, because the key contained last_changed for the posts group and that marker moved. On a site with one editor this is a good trade. On a site where a stock feed writes meta rows every minute, the marker moves constantly and a query cache over that data will almost never hit, which is a finding worth having before you spend a week tuning it.

A hit is not automatically cheaper than a miss

The cost of a hit is a lookup plus unserialising the value; the cost of a miss is computing it, and for a value that is one get_option() call away in the already-loaded options blob, computing it is a couple of array lookups. Store values whose rebuild is expensive or whose read count is high, and measure both sides before deciding. The hit rate counts entries, not savings, so a cache full of cheap scalars can read 99% and change nothing.

An evicted entry is a miss, so the miss path is the one to test

The other side of that trade is memory, and it is the reason the shipped defaults are not aggressive. Every entry you keep is a serialised value in the backend, and a persistent backend evicts under memory pressure without telling anyone. Losing an entry to eviction is not a correctness problem, since the miss path is the same code that built the value the first time, which is exactly why the miss path deserves as much attention as the hit path in this module.

Lab 40 minutes

Find out what your own install hands to your templates before they run. Put this in a must-use plugin and load any front-end page once:

add_action( 'muplugins_loaded', function () {
    global $wp_object_cache;
    if ( ! isset( $wp_object_cache->cache ) ) {
        return;
    }
    foreach ( $wp_object_cache->cache as $group => $keys ) {
        error_log( sprintf( 'muplugins_loaded: %s (%d keys)', $group, count( $keys ) ) );
    }
} );

Then answer these, with the numbers in your notes:

Which groups are populated, and for each one, name the core function that put it there. How many of those entries would disappear if the request ended right now? Is wp_using_ext_object_cache() true, and does the class name of $wp_object_cache match what your host or plugin claims to have installed?

Finally, pick one value your site computes on the front end, store it with wp_cache_set() in a group of your own, and read it in a second request without a page reload. If the second request finds it, you have a persistent cache and a new class of bug to look for. If it does not, you have the default backend and no invalidation problem yet.

Show the solution and the reasoning

The groups you find at muplugins_loaded are the ones core writes while bootstrapping, and on WordPress 7.0.7 the list is stable: options with alloptions and notoptions from wp_load_alloptions(), site-options and site-transient on multisite and single site alike, themes from wp_get_theme(), translation_files from the translation loader, and a couple of entries in default written by whichever component ran first. All of them disappear at the end of the request on the default backend, which is why they are cheap: rebuilding them is a couple of queries plus some file reads.

The version check is the part people skip and then regret. wp_using_ext_object_cache() answers the version question and not the health question. A drop-in that throws while connecting takes the site down rather than degrading, a drop-in that catches its own connection error and falls back to an array leaves you with ext: true and no persistence, and one that is pointed at a shared Redis instance is working exactly as configured while another site's flush empties your cache. Printing the class name catches the second case; the first and third need the host's logs and the drop-in's configuration, which is why the lab asks for both.

For the second-request test, use a key and a group that clearly belong to you, and store something with a timestamp in it so a stale copy is obvious. With a persistent backend the value comes back and you now own its invalidation. Without one you cannot reproduce the invalidation bugs described in this module at all, which is worth knowing before you spend a day chasing one on a development machine that has no drop-in installed.

One result that is easy to misread: if your second request is served by a page cache, no PHP runs at all and the cache you are testing is not the object cache. Load a URL that is excluded from page caching, or run the read from WP-CLI, where the page cache is not involved.

Progress

Mark this lesson done and it stays marked in this browser.