What Actually Clears W3 Total Cache's Page Cache (It's More Than You Think)
In my last post I dug into what W3 Total Cache actually stores on disk, raised the cache lifetime to 30 days, and set a crawler loose to keep a large site permanently primed. Problem solved, I thought.
Then I checked the numbers. One evening the cache held around 3,000 pages. By morning it was down to 1,000. With a 30-day lifetime and garbage collection running once a day, expiry couldn’t explain it. Something was actively deleting cached pages faster than the crawler could rebuild them.
So I went through W3TC’s source and traced every code path that can clear the page cache. What I found applies to any site running W3TC - and a couple of the triggers are surprising enough that I think every W3TC user should know about them.
First, rule out the usual suspects
My first guess was another plugin doing full flushes behind my back - Yoast has a reputation for this sort of thing. So I grepped every plugin on the site for w3tc_flush_all() and friends. Result: apparent exoneration. Every full-flush caller looked gated behind a manual admin action:
- Yoast SEO full-flushes only when its own options are saved - which I filed under “rare, manual settings changes”.
- OMGF only flushes after an admin saves its settings.
- Elementor only flushes when maintenance mode changes.
- Post Types Order flushes on a manual drag-reorder, Email Address Encoder on its settings save.
Nothing scheduled, nothing on post updates. Case closed on the plugins, I wrote in my notes.
Hold that verdict. One of these suspects comes back at the end of the post - and the way it got caught is the most useful part of the whole investigation. But first, the things I learned about W3TC itself along the way, because they set up the trap.
The purge policy applies to every post type
The Purge Policy settings say: “Specify the pages and feeds to purge when posts are created, edited, or comments posted.” Posts, right?
No. In Util_Environment::is_flushable_post(), the only post types excluded are revision and attachment. Every published post of any post type goes through the full purge policy on save: WooCommerce products, events, portfolio items, custom post types of every kind. And “on save” means any save - not just an editor clicking Update, but every programmatic wp_update_post() from an importer, a stock sync, a feed integration or a cron job.
A single save also purges more than the post. With a fairly standard policy, PgCache_Flush::flush_post() builds a URL list of:
- the post’s own URLs,
- for a custom post type, up to 10 pages of its archive (that’s the “Purge home” option - for CPTs it purges the post type archive, not your front page),
- and, whenever the sitemap regex option is set, the entire cached sitemap group, every time, for every post.
Mostly this is what you want - stale product pages are worse than cold ones. But it means the volume of cache churn scales with how much of your content changes per day, not with how much editors touch. On the site in question, yacht listings sync from an external feed, so every price change was quietly purging a page, ten archive pages and all the sitemaps. A busy WooCommerce store does the same thing every time stock levels tick over.
Saving a menu wipes the entire cache
While reading Util_AttachToActions.php (where W3TC attaches its “content changed” hooks), I found the list of triggers that call w3tc_flush_posts() - which for the page cache means flush everything:
add_action( 'switch_theme', 'w3tc_flush_posts', 0, 0 );
add_action( 'wp_update_nav_menu', 'w3tc_flush_posts', 0, 0 );
add_action( 'edited_term', 'w3tc_flush_posts', 0, 0 );
add_action( 'delete_term', 'w3tc_flush_posts', 0, 0 );
Read that middle pair again. Editing or deleting any taxonomy term flushes the whole page cache. Rename a category, fix a typo in a tag, tidy up some old terms: the entire site’s cache is gone, once per term. Same for saving a menu. If someone does a bit of housekeeping in the admin at 11pm, that’s your mysterious overnight cache wipe right there.
There’s logic to it - a renamed category can appear on any page, and W3TC can’t know which - but the pricing is brutal, and nothing in the UI warns you.
Two mercies, confirmed in the same file:
- Creating a term doesn’t flush anything -
create_termisn’t hooked. Plugins that add new terms as content comes in do no damage. - Post meta updates don’t flush either. Only actual post saves (
save_post, trash, delete) trigger the purge machinery. Code that skipswp_update_post()when nothing has really changed causes zero cache activity - worth knowing if you write anything that syncs content.
If you ever need to do bulk term maintenance, W3TC provides a pre-flush filter that lets you hold the flood back and flush once at the end:
add_filter( 'w3tc_preflush_posts', '__return_false' );
// ... rename / merge / delete terms ...
remove_filter( 'w3tc_preflush_posts', '__return_false' );
w3tc_flush_posts(); // one flush instead of hundreds
The double flush
To see the purge activity instead of guessing at it, I added an activity log to my W3TC Cache Insights plugin that records every flush event W3TC fires (v0.2.0 also keeps per-day counters, because a rolling feed scrolls away fast on a busy site). It immediately showed something odd: two “flush post” events for every item my importer updated, about a minute apart.
The cause is a footgun that applies to any plugin that both saves posts and clears cache - a very common combination. W3TC hooks save_post at priority 0, so the moment code calls wp_update_post(), W3TC has already queued a purge - before the plugin has written the post’s meta fields and taxonomy terms. When the plugin then calls w3tc_flush_post() itself at the end of processing (as many do, to be “cache friendly”), that’s purge number two. Everything from the previous section doubles: two purges of the page, twenty archive-page purges, two sitemap flushes. Per item.
The fix is the per-post version of the same pre-flush filter, wrapped around the save:
add_filter( 'w3tc_preflush_post', '__return_false' );
wp_update_post( $args );
remove_filter( 'w3tc_preflush_post', '__return_false' );
// ... write meta, set terms, index ...
w3tc_flush_post( $post_id, true ); // one purge, after the data is complete
Now the only purge happens when the post is actually finished. If you use a plugin that updates content automatically and the flush log shows doubles, this is almost certainly why.
Purges don’t happen when you call them
This was the biggest surprise of the lot, and it invalidated a “fix” I’d already shipped.
To soften the purge churn, I’d made our importer re-prime each page after flushing it: purge the page, then fire a non-blocking loopback request so the page is regenerated and back in the cache before a real visitor arrives. Sensible, right?
Except W3TC doesn’t delete anything when you call w3tc_flush_post(). It queues the purge and executes it later - on the shutdown hook at priority 100000 (CacheFlush.php):
add_action( 'shutdown', array( $this, 'execute_delayed_operations' ), 100000, 0 );
So the sequence was actually: queue the purge, fire the loopback request - which gets served the old cached page, because it still exists - and then, at shutdown, W3TC deletes both the stale page and the “freshly primed” copy, which was the same stale file all along. The re-prime was a no-op. The page ended up uncached anyway, waiting for the next visitor to pay the regeneration cost.
This matters to anyone doing cache warming around purges: any priming you do inside the same request as the purge is wasted. Once you know the timing, the fix is mechanical: prime on shutdown too, at a later priority than 100000. And since W3TC fires public actions (w3tc_flush_post, w3tc_flush_url) for every purge, one small listener covers every purge on the site, whoever triggered it:
add_action( 'w3tc_flush_post', function ( $post_id ) {
// resolve the permalink now and queue it...
}, 20 );
add_action( 'shutdown', 'my_prime_queued_urls', 200000 ); // after W3TC's 100000
Queueing has a bonus: it dedupes. A bulk operation that flushes the same archive page fifty times in one request primes it once.
I’ve published this as a tiny free plugin, W3TC Flush Re-prime - no settings, no stored data. Activate it and purged pages come back into the cache within seconds instead of waiting for a crawler or an unlucky visitor.
Setting the trap: the purge log
All of that source reading explained the churn, but the cache was still collapsing - nearly 2,000 pages down to 303 in a single evening. Static analysis had taken me as far as it could. Greps tell you what code can do; only a log tells you what it did.
W3TC has exactly the log I needed: a purge log that records every page cache purge with the user, IP, request URI and a full backtrace. The checkbox for it sits behind the Pro licence, but the setting itself is a plain boolean that the free engine honours - you can switch it on from the command line:
wp w3-total-cache option set pgcache.debug_purge true --type=boolean
The log appears under wp-content/cache/log/, one file per module. Two caveats: saving W3TC’s settings in the admin can silently reset the flag, and the file grows fast on a busy site, so treat it as a diagnostic tool rather than something to leave on forever.
I switched it on, and the next day the trap had caught the culprit. Thirteen full-cache wipes in one day - and every single backtrace ran through the same two frames:
WPSEO_Utils::clear_cache()
w3tc_flush_posts()
Yoast. The suspect I’d exonerated on day one.
Yoast wipes the cache every time its option is written
Here’s what my grep had found but I’d misjudged: Yoast hooks WPSEO_Utils::clear_cache() - which calls w3tc_flush_posts(), a full page cache wipe - to add_option_wpseo, update_option_wpseo and update_option_wpseo_titles. Any write to those options clears the entire cache. I’d filed that under “rare, manual settings changes”, because how often does anyone save Yoast’s settings?
The flaw in that reasoning: the wpseo option isn’t just settings. It’s also where Yoast keeps runtime bookkeeping, and the bookkeeping gets rewritten constantly. The backtraces showed two separate writers.
Writer one: the indexing command. The overnight wipe (04:54, from localhost) was a cron job running wp yoast index. The first thing the indexer does is write an indexing_started timestamp - into the wpseo option. Full cache wipe, every night, from a housekeeping timestamp that changes nothing on any rendered page.
Writer two: the one that took the prize. The daytime wipes traced back to Yoast’s post type and taxonomy “change watchers”, which run on every admin_init and compare the site’s current public post types and taxonomies against a last-known list stored in - you guessed it - the wpseo option.
That’s harmless as long as the list is stable. On this site it wasn’t, because of a completely unrelated plugin: Complianz (the premium version) registers a region taxonomy and two internal post types, all technically public, only for users who can manage privacy settings. For everyone else they simply don’t exist.
Follow that through. An author opens the Media Library: the Complianz types aren’t registered for her, Yoast’s watchers see taxonomies that have “gone private”, and save the shrunken list - five option writes, five full cache wipes, in one second. An administrator opens the dashboard sixteen minutes later: the types are back, Yoast sees “new public taxonomies”, saves again - seven more wipes. Neither of them touched a single setting. They wiped the site’s entire page cache by looking at wp-admin.
The general lesson is bigger than these two plugins: any plugin that registers a public post type or taxonomy conditionally - by role, by context, by feature flag - turns Yoast’s watchers into a cache-wiping metronome, paced by which of your users happens to open the admin next. And because it looks like nothing (nobody did anything!), you will never find it by asking “what changed?”.
The kicker: Yoast knows this hook is a hair trigger. There’s a class in Yoast itself that removes clear_cache before writing one of its own bookkeeping keys, then adds it straight back afterwards.
Defusing it
That re-add is why the obvious fix - a one-time remove_action() - isn’t reliable. Yoast can re-register the hook mid-request, after your code has run. The robust version strips the callback at priority 1 of the firing hook itself, so it wins no matter how many times the hook gets re-added:
function strip_yoast_cache_wipe() {
remove_action( current_action(), array( 'WPSEO_Utils', 'clear_cache' ) );
}
foreach ( array( 'add_option_wpseo', 'update_option_wpseo', 'update_option_wpseo_titles' ) as $hook ) {
add_action( $hook, 'strip_yoast_cache_wipe', 1 );
}
Drop that in an mu-plugin and the wipes stop. Yoast’s internal options cache handling is untouched - this only removes the page-cache flush. The trade-off is that a genuine Yoast settings change no longer purges automatically, so purge manually after real changes that affect rendered pages.
If you have the conditional-registration problem too, fix it at the source as well: force the flapping types to public => false via the register_post_type_args and register_taxonomy_args filters, and Yoast’s watchers stop seeing ghosts.
The checklist
If your W3TC page cache keeps shrinking and the lifetime maths doesn’t add up:
- Check the lifetime on the server, not from memory -
wp w3-total-cache option get pgcache.lifetime. (Mine really was 30 days this time. It isn’t always.) - Count the flushes. Cache Insights records every flush event with what triggered it, plus daily totals. A “Flush all posts” entry at 11pm points straight at a term edit, a menu save - or an SEO plugin.
- Get backtraces with the purge log.
wp w3-total-cache option set pgcache.debug_purge true --type=booleanworks on the free version and names the exact code path behind every purge. Don’t trust a grep-based exoneration when a log can give you a confession. (And if the cache drops while the purge log stays silent, the culprit is below WordPress entirely - a host process or backup script touching the cache directory.) - Remember the purge policy hits every post type - and each save also purges CPT archive pages and all sitemaps. Anything that saves posts programmatically (importers, stock syncs, feed integrations) drives cache churn you never see in the editor.
- Watch for the double flush. If a plugin both saves posts and calls
w3tc_flush_post()itself, suppress W3TC’s automatic one withw3tc_preflush_post. - Don’t prime inline after a purge. It does nothing - the purge hasn’t happened yet. Prime on
shutdownafter priority 100000, or just use the plugin.
The double flush is fixed, purged pages re-prime themselves, and the plugin I’d cleared on day one turned out to be wiping the entire cache every time someone opened wp-admin. The cache now holds steady - and the tool that closed the case was a log file that ships with the free version of W3TC, switched on from the command line.