wp cache flush doesn’t flush what your site is actually serving
Three times in one week I changed something on this site, verified the change, and watched the old version keep going out. Each time the change was correct. Each time a different cache was holding it — and the command I used to check was quietly testing a layer that wasn't the problem.
This site runs WordPress behind W3 Total Cache, memcached and Cloudflare. That’s five places a copy of a page can live if you count the browser, and on 9 September 2026 I managed to get caught by three of them in a single afternoon.
None of it was exotic. What made it cost a day was that every check I ran changed which cache I was testing, so the evidence kept pointing at the wrong layer. That’s the part worth writing down — the commands are trivia, the verification order isn’t.
Everything below is from W3 Total Cache 2.10.6, and I’ve gone back and read the code rather than leaving it at “seems to behave like”.
The ?cb=1 trap
Start here, because this one poisons everything downstream.
W3TC’s disk page cache was on. The options table said otherwise, or so I thought:
wp option get w3tc_pgcache_enabled
Error: Could not get 'w3tc_pgcache_enabled' option. Does it exist?Two things are wrong with reading that as “off”. W3TC doesn’t keep its settings in wp_options at all — they live in wp-content/w3tc-config/master.php, a JSON blob behind a PHP guard line, and in there pgcache.enabled is true. And WP-CLI reports a missing option and an option whose value is literally false with the same message, because all it checks is whether get_option() came back false. So even if the setting had lived there, that command couldn’t have told me which one I was looking at.
What actually serves the cached page is a rewrite rule in .htaccess. Trimmed to the load-bearing lines:
RewriteCond %{REQUEST_METHOD} !=POST RewriteCond %{ENV:W3TC_QUERY_STRING} ="" RewriteCond %{HTTP_COOKIE} !(comment_author|wp\-postpass|w3tc_logged_out|wordpress_logged_in|wptouch_switch_toggle) [NC] RewriteCond "%{DOCUMENT_ROOT}/wp-content/cache/page_enhanced/%{HTTP_HOST}/..." -f RewriteRule .* "/wp-content/cache/page_enhanced/%{HTTP_HOST}/..." [L]
Four conditions, and three of them are things you trip over by reflex the moment you start debugging.
The query string has to be empty — but not the real one. W3TC_QUERY_STRING is a copy, and the 200 lines of .htaccess above this rule exist to strip 92 known tracking parameters out of that copy first: every utm_*, plus fbclid, gclid, mc_cid, igshid, msclkid, epik and friends. That’s a sensible feature — someone arriving from Facebook should still get the cached page. It also means the emptiness being tested is a fiction you can’t see in your own URL bar.
It can’t be a POST, which is fine, and there must be no wordpress_logged_in cookie — or comment-author cookie, or postpass cookie. You are logged in. Every page you look at while debugging is being generated fresh, for you, by PHP.
So the cache-busting query string — the thing you append precisely because you want to be sure you aren’t looking at a cached copy — is what guarantees you never see the cached copy:
curl -s https://example.com/ # served from wp-content/cache/page_enhanced curl -s https://example.com/?cb=1 # bypasses it entirely, renders fresh
Your logged-in browser does the same thing for a different reason. You conclude the page cache is innocent and go looking elsewhere, while every logged-out visitor arriving on a bare URL keeps getting the old HTML.
One trap inside the trap: pick your cache-buster from outside that list of 92. ?b=1757430000 is fine. ?ref=1 is not — ref is on the strip list, so W3TC removes it, the string it tests is empty again, and you are served from cache while believing you bypassed it. Same for si, pp and redirect_mongo_id.
Note the last two lines as well: the cached file lives under page_enhanced/%{HTTP_HOST}/. Hold onto that. The host comes back in the next section, and it’s the same bug wearing a different hat.
I now purge the page cache explicitly after every deploy rather than trusting any flag:
find <docroot>/wp-content/cache/page_enhanced -type f -name '_index*' -delete find <docroot>/wp-content/cache/minify -type f ! -name index.html -delete wp cache flush
Which brings us to that last line, and the reason it doesn’t do what it says.
The object cache flush that lands in a namespace nobody reads
Same day. I ran wp post update on a published post. The database took it — I byte-compared the stored post body to confirm, so this wasn’t a failed write. The site kept serving the old body.
The page cache wasn’t the culprit either: the request was already going through with ?b=<timestamp>, which per the section above bypasses it.
Then a second one, shaped identically. I ran wp plugin deactivate on a plugin and origin output stayed byte-for-byte the same, query string and all. A deactivated plugin still running is an alarming thing to look at, and I drew two confident, wrong conclusions about why before the actual answer turned up.
The answer was the object cache, and the mechanism is worth spelling out, because it is not what “flush” suggests.
Two pieces of W3TC. The first builds the key for every item it stores in memcached — Cache_Memcached::get_item_key():
w3tc_<instance id>_<host>_<blog id>_<module>_<md5 of the item name>
That <host> is Util_Environment::host(), which is $_SERVER['HTTP_HOST'], or an empty string when there isn’t one. WP-CLI is a shell. There is no request, so there is no HTTP_HOST, so every key it computes lands in the empty-host namespace.
The second piece is the flush itself, ObjectCache_WpObjectCache_Regular::flush(). It deletes nothing. It invalidates by counter: read a value called key_version_all, add one, write it back. Every cached entry records the counter it was written under, and an entry whose counter is behind gets treated as a miss.
And key_version_all is stored as an ordinary cache item — so it goes under an ordinary key, with the host in it.
That’s the whole bug. From the CLI you increment a counter in the empty-host namespace. The web context reads its own counter, which nobody has touched, concludes that everything it is holding is current, and serves it. Your flush succeeded. It succeeded somewhere else.
You can see it in the keyspace. Dumping every key on the box, they all carry a host:
214 w3tc_<instance>_example.com_0_object_… 16 w3tc_<instance>_198.51.100.7_0_object_… # bots hitting the IP directly 8 w3tc_<instance>_www.instagram.com_0_object_… # bots with a spoofed Host header
Host is client-controlled, so anything that sends one gets a namespace of its own, scanners included. Harmless here, and a good illustration of how literally that field is taken.
It’s also why the plugin deactivation looked inert. active_plugins lives in alloptions, the autoloaded options bundle, which memcached was holding under the site’s namespace. The database row changed. The bundle didn’t, the web context kept loading the plugin from it, and the flush I ran to fix that went into a namespace the site never reads.
The fix is to hand WP-CLI the context it’s missing:
sudo -n -u www-data env HTTP_HOST=example.com HTTPS=on \ /usr/local/bin/wp --path=<docroot> cache flush
Two things here are easy to miss. The admin UI’s Purge All Caches button has never had this problem, because it runs inside a request that has a Host — which is exactly why the bug stays invisible until you start scripting. And wp cache flush is the object cache only; it is not W3TC’s page cache, minify or CDN. Those are wp w3-total-cache flush all, or the find lines above.
The other tempting fix is systemctl restart memcached. It does work. It’s also a production bounce to resolve a key-prefix mismatch, which is a bad trade if anything else on the box shares that memcached.
The rule that falls out of it: always run the env-prefixed flush after any CLI change to plugin or option state, then re-verify. Not because the write failed. Because it succeeded into a store the site isn’t reading.
If you want to confirm the same thing on your own install, it’s two greps — get_item_key in Cache_Memcached.php and key_version_all_increment in ObjectCache_WpObjectCache_Regular.php — and, if you have shell access to memcached, lru_crawler metadump all to see the namespaces for yourself.
A verification order that actually works
This is the transferable part. When a change to a WordPress site doesn’t show up, check in this order, because each step rules out exactly one layer:
- Are you logged in? If so you are bypassing the page cache entirely, and everything you see is freshly generated. Use
curl, or a private window. - Confirm the database changed. Byte-compare if you can. If the DB is wrong, nothing below matters and you have a different bug.
- Fetch with a cache-busting query string that isn’t on W3TC’s strip list (
?b=<timestamp>). Fresh output here means the page cache is holding it — purgepage_enhanced. - If it’s still stale with the query string, it is not the page cache. It’s the object cache namespace. Run the env-prefixed flush.
- If it’s stale only for assets, it’s the browser or the edge, and the next two sections are yours.
Step 4 is the one I didn’t have, and it’s the one that cost the afternoon.
The trap I nearly built: minify plus a one-year lifetime
This one never fired, and it’s the most frightening of the lot, because I set it up myself and only saw it while changing something adjacent.
W3TC minify writes combined assets to cache/minify/<hash>.css. The important and non-obvious property: when the source changes, the file is regenerated under the same filename.
Separately, I was fixing the browser cache lifetime for CSS and JS, which had been sitting at 3153600 seconds. That’s 36.5 days — a dropped zero. The intended value was 31536000, one year.
Put those two together and think about what a visitor gets. Minify is on, so the URL they’re handed is cache/minify/<hash>.css. That URL is now stamped max-age=31536000. You ship a fix, minify regenerates the file at the same path, and every visitor who already has it keeps the stale copy for a year, in their own browser, where no purge you can run will ever reach them. Not a W3TC purge, not a Cloudflare purge, nothing. There is no recovery except waiting, or changing the URL.
The order I did it in mattered more than the change itself: minify went off first, then the lifetime went up. With minify off, assets are served straight from the theme at ?ver=<theme version>, so the version bump changes the URL — and a changed URL clears browser, W3TC and Cloudflare in one move. That’s what makes a one-year lifetime safe rather than reckless.
The general rule: a far-future max-age is only safe on URLs that change when the content changes. Content-hashed filenames are fine. A stable filename with regenerated contents is a trap, and the longer your lifetime the worse it is. If you ever re-enable minify, reverse the lifetime first.
For the record, minify was buying about 3.6 KB compressed on a first visit and no saved request, since the theme already ships one CSS file and one JS file. That is not a good price for a mechanism that can pin a stale stylesheet to a visitor for twelve months.
And Cloudflare, which your W3TC purge does not touch
The fifth layer. HTML comes back cf-cache-status: DYNAMIC and is never edge-cached, so markup changes appear instantly. Assets come back HIT, with an age of days and that same one-year max-age.
Since asset URLs now carry the theme version, a deploy that bumps it needs no Cloudflare purge at all. A deploy that edits an asset without bumping it still does — and that’s precisely the deploy where you’ll forget, because nothing in your own tooling reminds you.
The rule
Every layer here was doing its job correctly. The site was broken because I couldn’t see which one was answering.
So: when a change doesn’t take, don’t start flushing things. Work out which cache is serving the response you’re looking at, and check whether the command you’re using to verify has quietly moved you to a different one. A cache-busting query string that skips the page cache, a login session that does the same, and a CLI flush that writes to the wrong namespace are all one bug in different clothes — your diagnostic changed the system, and then you believed it.