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 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. The commands are easy to look up. The order you check things in is what matters.
Everything below is from W3 Total Cache 2.10.6.
The ?cb=1 trap
This one comes first, because it throws off every check after it.
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 lines that matter:
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 sensible, since someone arriving from Facebook should still get the cached page. It also means the query string being tested isn’t the one in your URL bar.
It can’t be a POST, which is fine, and there must be no wordpress_logged_in cookie, comment-author cookie or postpass cookie. If you’re logged in, every page you look at while debugging is generated fresh by PHP.
So the cache-busting query string, added 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.
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.
The last two lines matter too: the cached file lives under page_enhanced/%{HTTP_HOST}/. The host comes up again in the next section.
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
That last line 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 the same thing again. 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 it doesn’t work the way “flush” suggests.
Two pieces of W3TC are involved. The first is Cache_Memcached::get_item_key(), which builds the key for every item it stores in memcached:
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.
So from the CLI you increment a counter in the empty-host namespace. The web context reads its own counter, which nobody has touched, decides everything it’s holding is current, and serves it. The flush worked, just not in the namespace the site reads.
Dump every key on the box and 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.
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 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.
Always run the env-prefixed flush after any CLI change to plugin or option state, then re-verify. The write didn’t fail. It went into a store the site isn’t reading.
To confirm this on your own install, grep for get_item_key in Cache_Memcached.php and key_version_all_increment in ObjectCache_WpObjectCache_Regular.php. With shell access to memcached, lru_crawler metadump all shows the namespaces.
A verification order that actually works
When a change to a WordPress site doesn’t show up, check in this order. Each step rules out 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 a page cache is holding it: W3TC’s on disk, or Cloudflare’s at the edge. Purgepage_enhancedfirst, then Cloudflare. - 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, covered in the next two sections.
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, but it’s the worst of the lot, because I set it up myself and only noticed while changing something next to it.
W3TC minify writes combined assets to cache/minify/<hash>.css. 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. 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. Neither a W3TC purge nor a Cloudflare purge helps. The only fixes are 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.
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.
Minify was saving about 3.6 KB compressed on a first visit and no requests, since the theme already ships one CSS file and one JS file. That’s not worth a mechanism that can pin a stale stylesheet to a visitor for twelve months.
And Cloudflare, which your W3TC purge does not touch
HTML is cached at the edge too. W3TC sends cache-control: max-age=3600, public on posts and pages, and a Cloudflare cache rule respects it, so a plain URL can keep serving the old page for up to an hour after the origin has the new one. Assets come back HIT, with an age of days and that same one-year max-age.
After a content change, purge W3TC first and Cloudflare second. The other way round, Cloudflare’s next miss fetches W3TC’s stale copy from disk and keeps it for another hour. A ?b=<timestamp> fetch still reaches the origin, because the query string is part of Cloudflare’s cache key.
Don’t load the plain URL until the ?b= fetch shows the change. If the object cache is still handing out the old content, that one request writes the old page back into W3TC’s disk cache and onto the edge.
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 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.
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 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 all fail the same way: the check changes what you’re looking at.