Guide
Why your WordPress cache will not clear.
A change is live on the server and the world still sees the old page. The instinct is to press purge again, harder. The real work is finding which of the four caches stacked in front of your site is holding the copy, because only one of them is listening to that button, and two of the ways it fails survive every purge you can press.
Nearly every stubborn caching problem is one of two things wearing the same symptom. Either the change never reached the layer you are testing, or the invalidation logic runs a sensible-looking loop over a relationship that does not cover every case. This guide separates the two and works through both, in the order we actually work through them on a live site. The examples come from one cache invalidation job on a busy directory site, where six pages were found sitting 14 to 24 hours stale and the cause turned out to be four separate faults at once.
Before anything else: find where the change stopped
Do not touch a purge button or a line of code until you know whether you have a cache problem or a deploy problem. They look identical from the front and they are fixed in opposite halves of the stack. The test takes two minutes.
Ask the origin for the page in a way nothing in front of it can answer, then compare that against what the public gets. A cache-busting query string is the quick version; hitting the origin host or IP directly is the honest version, because it removes the CDN from the path rather than trusting it to treat a query string as a separate key.
# what a visitor actually receives, and whether the edge served it
curl -sI https://example.com/some-page/ | grep -i cf-cache-status
# the same page with the edge cut out of the path
curl -s "https://example.com/some-page/?cb=$(date +%s)" | grep "the thing you changed" Two outcomes, and they send you in opposite directions.
- Origin correct, public URL stale. This is a cache problem and your code is fine. Close the editor and go find which layer is holding the old copy.
- Origin also stale. This is not a cache problem, or not only one. The change either did not deploy or deployed and is not executing. Purging now is wasted effort, and it will convince you the purge failed when it worked perfectly.
The wrong turn we see most
Teams skip this test because they are certain the deploy went out. It did. The compiled copy of the old file is still resident in memory, which is a different problem with an identical face. Two minutes of curl tells the two apart and saves the afternoon that guessing costs.
The four caches, stacked
"Clear the cache" is ambiguous because there is no such thing as the cache. A request passes through these four in order, the first one able to answer does, and every layer behind it never hears that anything changed. The button most people know clears exactly one of them.
The CDN edge
Cloudflare or similar, holding rendered HTML at hundreds of locations under an s-maxage that
is often 24 hours. This is the layer behind "it is right for me and wrong for everyone else", because your
logged-in session skips the edge and nobody else's does.
OPcache
The server's cache of compiled PHP. It does not hold pages, it holds your code, so when it is stale the old version of your function is still what runs. This is the layer that gets missed and wastes the most time, because the file on disk is visibly correct.
Redis object cache
Object Cache Pro or equivalent, holding query results and object lookups. A page can be rebuilt fresh from PHP and still assemble itself from stale rows, which reads as a half update: the layout moved, the data did not.
WordPress transients
Application-level caching of HTML fragments and expensive lookups, with expiries set by your theme and plugins, independent of everything above. A transient with a 12-hour life outlives three purges of the other layers without blinking.
So when someone says they cleared the cache, they mean they cleared one of these four. The only useful next question is which button they pressed.
OPcache is the layer that eats the afternoon
It earns its own heading because it is under-known and expensive. On managed hosts this is where the trap lives: on Cloudways, for example, "Purge Site Cache" clears the page cache and leaves OPcache completely untouched. Your compiled PHP does not move.
The afternoon then goes like this. You edit functions.php, deploy, purge site cache, test, and
nothing changes. You conclude the fix is wrong, so you rewrite it, deploy again, purge again, and still
nothing changes, because the server has been running the pre-edit compiled copy the entire time. You were
never testing your new code.
To actually clear it, restart php-fpm from the host panel or use a dedicated OPcache flush if the host exposes one. Put it in the deploy checklist so it is a step rather than something you eventually remember under pressure.
Rule of thumb
Changed a template or a page, suspect the edge. Changed PHP, suspect OPcache first, every time, before you touch the code again.
Why "purge everything" quietly costs you
The reflex fix for stale content is to fire the CDN's purge_everything on every change. It works,
in that the stale page disappears. It also carries a cost that stays invisible until you go looking for it.
On the directory site, that call ran on every content edit, and it was not only evicting HTML. It was evicting
a one-year AVIF image cache alongside it, so profile images were permanently cold. We sampled eight profile
pages and seven returned cf-cache-status: MISS on their images. Those images had been slow for
months and nobody had linked slow images to the purge logic, because the two sit in different mental boxes:
one is a performance problem, the other a freshness problem. They were the same problem.
The fix was a targeted purge: an explicit list of URLs, HTML only, images never touched. The image cache then survives content edits and stays warm at the edge for its full TTL. The trade is real and worth stating plainly: any page not on the list stays stale until its own edge TTL expires. There is no safety net now, which is exactly what forces the list to be right, and getting the list right is harder than it sounds.
The four ways a targeted purge list lies to you
This is the part to read twice. Six pages sat 14 to 24 hours stale while a purge fired correctly and reported success every time. Four independent causes, and the first pass found only the obvious one.
1. URLs simply missing from the list
The boring one everybody finds. A page type is added months after the purge logic was written and nothing connects the two. Worth checking first only because it is cheap. Do not stop here, which is the mistake we made on the first pass.
2. A delete path with no hook
Take an availability flag on a listing. Turning it on writes post meta and fires
added_post_meta and updated_post_meta. Turning it off deletes the meta and
fires neither. If your purge only listens to the first two, every activation invalidates and every
deactivation does not, so the bug looks intermittent when it is perfectly deterministic.
add_action( 'added_post_meta', 'queue_purge', 10, 4 );
add_action( 'updated_post_meta', 'queue_purge', 10, 4 );
add_action( 'deleted_post_meta', 'queue_purge', 10, 4 ); // the missing one
add_action( 'transition_post_status', 'queue_purge_status', 10, 3 ); transition_post_status belongs there too: publishing, unpublishing, trashing and restoring all
change what a page should show, and none of them is a meta update. The wider lesson outlives this one hook.
Enumerate every way the state can change, not just the happy path: create, update, delete, status change, bulk
edit, and whatever a scheduled job or an import does behind your back. Write them down and confirm each has a
hook.
3. Two URL forms for one page
get_term_link() returns the taxonomy permalink, say /location-city/soho/. On many
sites some terms are canonical at the root instead, say /soho/, with the taxonomy form issuing a
301 to it. Purge the redirecting form and you achieve nothing: you evict a URL that only ever returns a
redirect while the real cached page sits untouched. Purge both forms. Purging a redirect is harmless and costs
one line in a batch, so there is no reason to be clever about deciding the canonical form at runtime.
4. Pages that query content instead of being archives
This is the subtle one, and the one that survives every other fix.
Curated landing pages are very often WordPress Pages running a custom query, not taxonomy archives. To a visitor they look like categories. To the database they are not: no listing is ever tagged with them, so a purge that loops over an item's terms to decide which pages to invalidate can never reach them. The loop is correct. The relationship it walks does not exist. You have to enumerate them separately, by structure rather than taxonomy, and cache the lookup so it does not run on every save.
$urls = get_transient( 'curated_page_urls' );
if ( false === $urls ) {
$urls = array_map( 'get_permalink', get_pages( array(
'child_of' => CURATED_PARENT_ID,
) ) );
set_transient( 'curated_page_urls', $urls, 12 * HOUR_IN_SECONDS );
}
A 12-hour transient is a fair balance. New curated pages are rare, and a page created today becoming
purge-eligible tomorrow is an acceptable lag against the alternative of a get_pages() call on
every single save.
Batching, rate limits and running late
A common guard on purge code is a lock allowing one purge per 60 seconds. It protects the API and it silently swallows the second of two quick changes, which is precisely the pattern an editor makes when they save, spot a typo, and save again. The second save is the one that mattered and the one that gets dropped.
Queue rather than drop. Collect URLs across the request, deduplicate, and flush once. Three rules make it reliable:
- Chunk the list at around 30 URLs per API call, so one oversized batch is not rejected wholesale.
-
Run the flush on
shutdown. It does not block the editor, and by then every metadata write for the request has landed, so the list you build is the final state rather than a mid-save snapshot. - Log what was purged. When a stale page is reported next month, you want the record, not a theory.
Images invalidate by a different rule
Image invalidation does not follow the HTML rules and needs its own handling. Replace an image at the same URL and nothing in the HTML changes, so every HTML-only purge in the world leaves it stale: the edge holds a perfectly valid object under a URL that still resolves. Three things to get right.
- Hook the plugin, not just core. Optimisation plugins often write attachment metadata
directly and skip
wp_update_attachment_metadata, so a listener on the core filter never fires. Hook the plugin's own action. - Resolve the attachment to all its size variants. Purging the full-size file leaves every generated thumbnail cached. Build the list from the attachment metadata and purge each URL. Purging the source jpg also evicts the derived AVIF, which is the behaviour you want, and the reason the source URL must be in the list even when the page references it nowhere directly.
- Renamers change the URL. A plugin that renames files on upload leaves the old URL cached and still referenced anywhere the HTML was not rebuilt. Purge the old URL and purge the HTML of the post the attachment belongs to.
Two cache bugs that look like application bugs
A "load more" button that fails only on cached pages
The report was pagination working in the admin session and failing for everyone else, intermittently, with the AJAX request returning 403. The cause is a lifetime mismatch. A WordPress nonce is generated at render time and baked into the HTML, valid for roughly 12 to 24 hours. The CDN serves that same HTML for days. So the button carries a nonce that expired long before the page did, the handler correctly rejects it, and the failure looks random because it depends on how long ago that edge node cached that page.
We proved it by posting a nonce lifted from a cached copy and a nonce from a freshly rendered copy to the same endpoint. The fresh one succeeded, the cached one returned 403. For a public, read-only endpoint the fix is not a longer nonce or a cache bypass, it is to not require a nonce there at all. A nonce protects a logged-in user from being tricked into an action. Reading the next twelve public listings is not that, and pretending it is buys nothing while breaking the page.
Personalised HTML leaking into the shared edge cache
If any response can vary by user and any user's response can reach the edge, one visitor's personalised page
can be served to strangers. That is cache poisoning, not a performance nicety, and it is the one cache mistake
that can actually leak data. Guard it explicitly: logged-in requests, and anything carrying user-specific
output, get a private, no-store Cache-Control so the edge never stores them.
if ( is_user_logged_in() ) {
header( 'Cache-Control: private, no-store, max-age=0' );
} Do not lean on a CDN page rule alone for this. Set the header at the application, where the code already knows the response is personalised, and treat the page rule as a second line rather than the only one.
The verification checklist
Run it in order. It is short on purpose.
- Compare origin against the public URL with a cache buster. Decide file or cache before doing anything else.
- If you changed PHP, clear OPcache, and on managed hosts confirm the purge button actually does. Usually it does not.
- Check
cf-cache-statuson the stale URL and on its images separately. They fail independently. - Toggle the state off, not just on, and confirm the purge fires for the delete path.
- Check both URL forms for any term canonical at the root, and purge both.
- List every curated page that queries the changed item without being tagged with it, and confirm each is purged.
- Make two edits inside 60 seconds and confirm the second is not swallowed by the rate limit.
- Replace an image at the same URL and confirm every size variant is evicted, derived formats included.
- Load a page logged out and confirm no logged-in markup appears in it.
- Read the purge log. If nothing is logging, that is the next task.
What it comes down to
Two categories cover almost every case. Either the change never reached the layer you are testing, which the origin comparison settles in two minutes, or the invalidation logic has a correct-looking loop over a relationship that misses a case. Broad purges hide the second category by brute force, at a price you will not notice until you measure your image cache. Targeted purges expose it, which is uncomfortable at first and correct in the long run. Moving from one to the other, expect to find things. On the directory site we found four.
Nearly all of this lives in code rather than configuration: the hooks that fire the purge, the relationship the loop walks, and the edge cases neither covers. That makes it WordPress development work as much as caching work, and where the relationship is genuinely bespoke the honest answer is usually a purpose-built tool rather than another plugin with a broader purge button.
Keep reading
If this is your problem
Book me
Still stale after all that?
Send us the URL and what you have already purged. We will tell you which layer is holding it and whether the fix is a deploy step or a rewrite of your invalidation logic.