Guide

Why your WordPress cache will not clear.

You changed something. The change is live at origin. Visitors are still looking at the old version. This is a guide to finding out which of four caching layers is holding the old copy, and to the failure modes that survive a purge because nothing ever told the purge they existed.

Written from a live diagnosisIncludes the wrong turnsHook names, not vendor pitches

The symptom, and why it is confusing

Someone edits a page, saves it, and checks it. It looks right in the admin preview. It looks right when they are logged in. Then a colleague on a different network loads the same URL and gets content from yesterday. Half an hour later it is still yesterday. Someone clicks the purge button, twice, and it is still yesterday.

The confusion comes from a shared assumption that there is one cache. On a typical WordPress site behind a CDN there are at least four, they sit in front of each other, and the button most people know about clears exactly one of them. Worse, the layers fail in different ways. One holds stale HTML. One holds stale code, so your fix is not running at all and you will spend the afternoon debugging a file that is already correct.

What follows is the order we work in. It comes out of a cache invalidation job on a high traffic directory site, where six pages were found sitting 14 to 24 hours stale and the cause turned out to be four separate things at once.

First, prove whether it is the file or the cache

Do this before anything else. It takes two minutes and it decides which half of the stack you spend the next two days in.

Request the origin directly, with a cache buster query string so nothing in front of it can answer, and compare the response against the public URL.

# public URL, whatever the visitor gets
curl -sI https://example.com/some-page/ | grep -i cf-cache-status

# same page, cache busted, so the edge cannot answer from store
curl -s "https://example.com/some-page/?cb=$(date +%s)" | grep "the thing you changed"

If you can hit the host or the origin IP directly, better still, because that removes the CDN from the path entirely rather than relying on a query string being treated as a distinct cache key.

There are only two outcomes and they send you in opposite directions.

  • Origin is correct, public URL is stale. It is a cache problem. Nothing is wrong with your code. Stop reading the file.
  • Origin is also wrong. It is not a cache problem, or not only a cache problem. Your change did not deploy, or it deployed and is not executing. Purging anything at this point is wasted effort and will make you think the purge failed when it worked perfectly.

The wrong turn we see most

Teams skip this check because they are confident the deploy went out. The deploy did go out. The compiled copy of the old file is still in memory on the server, which is a different problem with an identical symptom. Two minutes of curl separates them.

The four layers, in request order

A request travels through these in order. The first one that can answer does, and the ones behind it never hear about your change.

The CDN edge

Cloudflare or equivalent, holding your rendered HTML at hundreds of locations under an s-maxage that is commonly 24 hours. This is the layer that produces the classic "it is right for me and wrong for everyone else" report, because your logged in session bypasses it and nobody else's does.

OPcache

The server's cache of compiled PHP. It does not cache pages, it caches your code. If it is stale, the old version of your function is still what runs. This is the one that gets missed, and the one that wastes the most time, because the file on disk is visibly correct.

Redis object cache

Object Cache Pro or similar, holding the results of database queries and object lookups. A page can be rebuilt fresh from PHP and still assemble itself out of stale query results, which looks like a partial update: the layout changed, the data did not.

WordPress transients

Application level caching of rendered HTML fragments and expensive lookups, with their own expiries set by your theme or plugins. Independent of everything above. A transient with a 12 hour life will happily outlive three purges of other layers.

When someone says they cleared the cache, they almost always mean they cleared one of these four. Ask which button they pressed.

The OPcache trap on managed hosting

This deserves calling out on its own because it is under known and it is expensive. On Cloudways, the "Purge Site Cache" control does not clear OPcache. It clears the page cache. Your compiled PHP is untouched.

So the sequence that burns an afternoon goes like this. You edit functions.php. You deploy. You purge site cache. You test. Nothing changed. You assume 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 whole time.

To actually clear it, restart php-fpm from the host panel, or use the host's dedicated OPcache purge if there is one. Make this a step in your deploy checklist rather than something you remember under pressure.

Rule of thumb

If you changed a template or a page, suspect the edge. If you changed PHP, suspect OPcache first, every time, before you touch the code again.

Why purge everything is the wrong default

The reflex fix for stale content is to call the CDN's purge_everything on every content change. It works, in the sense that the stale page goes away. It also has a cost that is invisible until you go looking for it.

On the site above, that call was firing on every content change. It was not only evicting HTML. It was evicting a one year AVIF image cache along with it, which meant profile images were perpetually cold. We sampled eight profile pages and seven of them were returning cf-cache-status: MISS on their images. Those images had been slow for months, and nobody had connected slow images to the purge logic, because the two things live in different mental categories. One is a performance problem, the other is a content freshness problem. They were the same problem.

The fix was a targeted purge: an explicit list of URLs, HTML only, images never touched. Image cache then survives content edits and stays warm at the edge for its full TTL.

The honest trade-off: any page that is not on that list stays stale until its own edge TTL expires. There is no safety net any more. That is precisely what forces you to get the list right, and getting the list right is harder than it sounds.

The four ways a targeted purge list goes wrong

This is the part worth reading twice. Six pages were sitting 14 to 24 hours stale despite a purge that was firing correctly and reporting success. Four separate causes, and the first diagnosis found only the obvious one.

1. URLs simply missing from the list

The boring one, and the one everybody finds. A page type gets added to the site months after the purge logic was written, and nothing connects the two. Worth checking first only because it is cheap to check. Do not stop here, which is the mistake we made on the first pass.

2. A delete path with no hook

Consider an availability flag on a listing. Turning it on writes post meta, which fires added_post_meta and updated_post_meta. Turning it off deletes the meta, which fires neither of those. If your purge only listens to the first two, every activation invalidates correctly and every deactivation does not. The bug therefore looks intermittent, and it is not.

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, because publishing, unpublishing, trashing and restoring all change what a page should show and none of them are a meta update.

The general lesson is bigger than this one hook. Enumerate every way the state can change, not just the happy path. Creation, update, deletion, status change, bulk edit, and anything a scheduled job or an import does behind your back. Write them down and check each one has a hook.

3. Dual URL forms

get_term_link() returns the taxonomy permalink, for example /location-city/soho/. On many sites some terms are canonical at the root instead, for example /soho/, with the taxonomy form issuing a 301 to it. Purging the redirecting form does nothing useful. You purge a URL that only ever returns a redirect while the real cached page sits there untouched.

Purge both forms. Purging a redirect URL is harmless and costs one entry in a batch, so there is no reason to be clever about deciding which form is canonical at runtime.

4. Pages that query content rather than being archives

This is the subtle one, and it is the one that survives every other fix.

Curated landing pages are very often WordPress Pages running a custom query, not taxonomy archives. They look like categories to a visitor. They are not categories to the database. No listing is ever tagged with them, so a purge that loops over an item's terms to work out which pages to invalidate can never reach them. The loop is correct. The relationship it depends on does not exist.

You have to enumerate them separately, by structure rather than by taxonomy, and cache that lookup so you are not running it 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 reasonable balance. New curated pages are rare, and a page created today being purge-eligible tomorrow is an acceptable lag when the alternative is a get_pages() call on every single save.

Rate limits and batching

A common protection on purge code is a lock allowing one purge per 60 seconds. It protects the API. It also silently swallows the second of two rapid changes, which is exactly the pattern an editor produces when they save, spot a typo and save again. The second save is the one that mattered and it is the one that gets dropped.

Queue instead of dropping. Collect URLs during the request, deduplicate, then flush at the end. Three rules that make this reliable:

  • Chunk the URL list, 30 per API call, so a large batch does not get rejected wholesale.
  • Run the purge on shutdown. It is non-blocking for the editor, and by then all metadata writes for the request have completed, so the list you build is the final state rather than a snapshot taken mid-save.
  • Log what was purged. When someone reports a stale page next month, you want the list, not a theory.

Auto-purging images is a separate problem

Image invalidation does not follow the same rules and needs its own handling. If an image is replaced at the same URL, nothing in the HTML changes, so every HTML-only purge in the world will not help. The edge has a perfectly valid cached object under a URL that still resolves.

Three things to get right:

  • Hook the plugin, not just core. Optimisation plugins frequently 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 instead.
  • Resolve the attachment to all of its size variants. Purging the full size file leaves every generated thumbnail and intermediate size 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 it is why the source URL must be in the list even when nothing on the page references it directly.
  • Renamers change the URL. A plugin that renames files on upload creates a different problem again: the old URL is still 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 do not look like cache bugs

A "load more" button that fails only on cached pages

The report was that pagination worked in the admin session and failed for everyone else, intermittently. The AJAX request was returning 403.

The cause is a lifetime mismatch. A WordPress nonce is generated at render time and baked into the HTML. It is 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 AJAX handler correctly rejects it, and the failure looks random because it depends on how long ago that particular edge node cached that particular page.

We proved it by posting the nonce from a cached copy of the page and the 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 lifetime or a cache bypass. It is to not require a nonce there at all. A nonce protects against a logged in user being tricked into performing an action. Reading the next twelve public listings is not an action that needs protecting, and pretending otherwise buys you 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 cache, then one visitor's personalised page can be served to strangers. This is a cache poisoning risk, not a performance nicety, and it is the one cache mistake that can actually leak data.

Guard it explicitly. Logged in requests, and anything else 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 rely on a CDN page rule alone to do 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.

A verification checklist

Run this in order. It is short on purpose.

  • Request the origin with a cache buster and compare it against the public URL. Decide file or cache before doing anything else.
  • If you changed PHP, clear OPcache. On managed hosts, confirm the purge button actually does this. Usually it does not.
  • Check cf-cache-status on the stale URL, and on its images separately. Images and HTML 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 that is canonical at root. Purge both.
  • List every curated page that queries the changed item without being tagged with it. Confirm each is in the purge list.
  • Make two edits within 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, including the derived formats.
  • Load a page as a logged out user and confirm no logged in markup appears in it.
  • Read the purge log. If it is not logging, that is the next task.

What this adds up to

Almost every stubborn caching problem we have diagnosed comes down to one of two things. 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 does not cover every case.

Broad purges hide the second category by brute force, at a cost 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. If you are moving from one to the other, expect to find things. On the site above we found four.

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.

Reply within one working day No obligation Your details stay with us

Takes about 60 seconds. No newsletter and no CRM sequence. Your details are used to reply to this enquiry and nothing else.