In short

A heavy, database-driven WordPress site can load like a flat HTML file on four cache layers, each with one job. The trick is not adding the caches. It is purging them from the inside out, so no outer layer ever re-caches a page the inner one just cleared.

A slow site costs you money in three places at once. Visitors give up before the page finishes painting, and each one who leaves was a customer you already spent money to attract. Google ranks faster sites higher, so speed quietly decides how many people ever find you. And behind the scenes, a page that lags makes your own editors stop trusting the tools they work in every day.

So the goal is not a good score in a speed test. The goal is more of your traffic turning into leads, more of those leads coming back, and a site that never embarrasses you in front of a customer. On one site I run, the platform for a national electrical services group, the pages are heavy: product data, a few calculators, a large library of articles. It still loads like a flat HTML file. This is exactly how, step by step, in a way you can copy.

You do not need a managed plan, you need a purge order

Four things change, and only one of them is the number in the speed test.

  • Visitors get pages instantly, so far fewer of them leave before the page loads, which means more of the traffic you already pay for turns into enquiries.
  • Search engines see a fast, stable site and rank it higher, so more new customers find you without spending more on ads.
  • Your editors change something and see it live immediately, so nobody is scared to touch the site and nothing sits wrong in front of a customer.
  • Your server stays calm under load, because repeat visits never touch it, so a busy day or a viral moment does not take you offline at the exact moment it matters most.

You do not need a magic plan or an expensive managed service to get there. You need a few cache layers that each do one job, and one small manager that clears them in the right order, targeted where it can be and thorough where it must be. Build that, and a heavy, dynamic site behaves like a static one, and behaves that way for your customers, not just in a test.

The mental model: caches are layers, each with one job

The mistake most people make is reaching for one cache plugin and expecting it to do everything. A single cache helps a little, but real speed comes from stacking a few caches so that each one does a single job well, and letting each visitor fall through them from the outside in.

Picture four layers sitting in front of your actual WordPress code.

Layer 1: the edge (Cloudflare)

This sits closest to your visitor, in a data centre near them physically, anywhere in the world. When it already has a copy of a page, it hands it over without ever touching your server. It also absorbs attacks and bad bots before they cost you anything. For a customer in Stockholm hitting a server in Helsinki, this is the difference between instant and loading.

Layer 2: the full-page cache (Varnish)

This lives on your server and keeps a finished copy of each HTML page in memory. When a repeat visitor asks for that page, Varnish hands back the whole thing in microseconds. PHP never runs, WordPress never boots. This is the layer that lets a heavy, database driven site behave like a static one.

Layer 3: the object cache (Redis)

Not every page can be fully cached. The moment a page is even slightly dynamic, WordPress starts asking the database questions, and it asks the same expensive questions over and over. Redis remembers the answers in memory, so the database is never asked twice for the same thing. This keeps the dynamic parts of your site, the search, the logged in views, the calculators, quick instead of crawling.

Layer 4: the page-cache plugin

This handles the front end polish: writing the static HTML in the first place, combining and shrinking files, loading images only when they scroll into view. Think of it as the layer that prepares clean, lightweight pages for the layers above it to store.

How a request actually flows

A first time visitor goes all the way down: Cloudflare has nothing, so it asks Varnish, which has nothing, so WordPress builds the page (using Redis to skip repeat database work), the plugin trims it, and every layer keeps a copy on the way back out. The second visitor gets that page from Cloudflare and never reaches your server at all. That is the whole game: build once, serve thousands of times from memory.

Build a page once, serve it thousands of times from memory, and your server never breaks a sweat no matter how busy the day gets.

The problem that turns a fast site into a liability

Here is the part almost nobody warns you about, and it is the part that actually matters.

Every layer keeps its own copy of every page. So the moment you change something in the admin, all four layers keep serving the old copy until you explicitly tell them to let go of it. An editor updates a price, hits refresh, and sees the old price. A customer sees the old price too. Now your fast site is actively working against you, because it is fast at showing the wrong thing.

Why just clear the cache is not a plan

The instinct is to clear everything, everywhere, all at once. That fails in a way that is hard to debug, because of ordering.

Say you clear the outer edge (Cloudflare) first. The very next visitor triggers Cloudflare to go fetch a fresh copy. Where does it fetch from? Varnish, one layer down, which you have not cleared yet and which is still holding the old page. Cloudflare grabs that stale page and caches it again as if it were fresh. You purged the cache and you are still serving old content, and now you have no idea why.

The rule that fixes it: purge from the inside out

Clear the innermost layer first and the edge last. Object cache, then page cache, then Varnish, then Cloudflare. That way, by the time each outer layer goes looking for a fresh copy, every layer beneath it is already fresh. The order is the whole trick. Get it right once, in one place, and stale pages stop happening.

Field note

Why order is the whole trick

On that platform the pages carry product data, calculators, and a large article library, yet they still load like a flat HTML file. Locking down the inside-out purge order in one place was the single change that made that possible.

The purge order is the whole fix, written down

Instead of sprinkling purge calls all over your theme and plugins, put them in one class with a fixed order. This is the single most important file in this whole setup.

Step 1: the skeleton and the fixed order

class Site_Cache_Purge {

    /**
     * Clear every layer, inside out, so no outer layer can ever
     * re-cache a stale page while fetching from the origin.
     */
    public static function purge_all() {
        self::purge_object_cache(); // 1. Redis / WP object cache
        self::purge_page_cache();   // 2. page-cache plugin
        self::purge_varnish();      // 3. full-page HTTP cache
        self::purge_edge();         // 4. Cloudflare, always last
    }
}

Every method below slots into that class. The order in purge_all() is not decoration, it is the fix from the section above written down.

Step 2: clear the object cache

WordPress already gives you one function for this when a persistent object cache like Redis is installed.

private static function purge_object_cache() {
    if ( function_exists( 'wp_cache_flush' ) ) {
        wp_cache_flush();
    }
}

Step 3: clear the page-cache plugin

Most page-cache plugins expose either a function or an action you can call. Check your plugin docs for the exact name, then wrap it so the rest of your code never needs to know which plugin you use.

private static function purge_page_cache() {
    // FlyingPress, for example, listens on its own action.
    // Swap this line for whatever your plugin exposes.
    do_action( 'flying_press_purge_everything' );
}

That single line is the one to be suspicious of, and I say that as the person it caught. do_action() does not tell you whether anybody was listening. If the plugin renamed the hook in an update, or the plugin is deactivated on that environment, or you typed it slightly wrong, the call still succeeds. PHP does exactly what you asked: it announces an event to an empty room and returns. There is no return value to check, because there is nothing to report.

I ran a purge routine of this exact shape for days while it cleared precisely nothing, because the method sitting behind the hook had been renamed and the failure was swallowed on the way past. Every purge reported success. Every report was accurate and completely useless. So write the call, wire it up, and then go and prove it removed something. There is a section further down that does exactly that, and it is the part of this article I would keep if I had to throw the rest away.

Step 4: clear Varnish

Varnish does not have a PHP function, it clears when it receives a special PURGE request for a host. WordPress can send that request to itself.

private static function purge_varnish() {
    $host = wp_parse_url( home_url(), PHP_URL_HOST );

    wp_remote_request( home_url( '/' ), array(
        'method'   => 'PURGE',
        'headers'  => array( 'Host' => $host ),
        'timeout'  => 5,
        'blocking' => false, // fire and forget, do not slow the save
    ) );
}

The blocking set to false matters. You do not want the editor Update button to sit there waiting for network calls. Fire the purge and move on.

Be honest with yourself about the price of that, though, because it is the same trap as the last one wearing a different hat. Fire and forget means fire and never find out. If Varnish is not listening, if PURGE is not permitted for your IP in the VCL, if the Host header does not match the backend definition, none of that comes back to you. The response you deliberately chose not to wait for is the response that would have told you it failed. That is still the right trade for a snappy editor experience, but it means the purge is unfalsifiable by construction, and something else has to do the checking.

Step 5: clear the edge (Cloudflare), last

Cloudflare clears through one authenticated API call. Notice this runs last, after every inner layer is already fresh.

private static function purge_edge() {
    $zone  = defined( 'CF_ZONE_ID' )   ? CF_ZONE_ID   : '';
    $token = defined( 'CF_API_TOKEN' ) ? CF_API_TOKEN : '';

    if ( ! $zone || ! $token ) {
        return; // nothing configured, skip quietly
    }

    wp_remote_post( "https://api.cloudflare.com/client/v4/zones/{$zone}/purge_cache", array(
        'headers' => array(
            'Authorization' => "Bearer {$token}",
            'Content-Type'  => 'application/json',
        ),
        'body'     => wp_json_encode( array( 'purge_everything' => true ) ),
        'timeout'  => 10,
        'blocking' => false,
    ) );
}

Step 6: keep your keys out of the code

Never paste an API token straight into a file that lives in your repository. Put it in wp-config.php, which stays out of version control.

// wp-config.php
define( 'CF_ZONE_ID', 'your-zone-id' );
define( 'CF_API_TOKEN', 'your-scoped-api-token' );

Scope that token to purge cache only. If it ever leaks, the worst anyone can do is clear your cache, not touch your DNS or your account.

Purge smart, not hard: targeted versus full purges

Clearing everything is simple, and for a small site it is fine. But on a big site it has a cost. Clearing everything means the next visitor to every page waits while it rebuilds. That is called a cold cache, and if you purge everything on every tiny edit, you are constantly making real customers pay for your edits.

The smarter move for routine edits is to clear only what actually changed: the post own URL, plus the handful of pages that show it, like the homepage and its category.

public static function purge_urls( array $urls ) {
    // Object and page caches usually flush all-or-nothing,
    // so on a targeted purge we still let those clear normally.
    self::purge_object_cache();
    self::purge_page_cache();

    // Varnish: send a PURGE per specific URL.
    foreach ( $urls as $url ) {
        $host = wp_parse_url( $url, PHP_URL_HOST );
        wp_remote_request( $url, array(
            'method'   => 'PURGE',
            'headers'  => array( 'Host' => $host ),
            'timeout'  => 5,
            'blocking' => false,
        ) );
    }

    // Cloudflare: purge a specific list of files, not everything.
    $zone  = defined( 'CF_ZONE_ID' )   ? CF_ZONE_ID   : '';
    $token = defined( 'CF_API_TOKEN' ) ? CF_API_TOKEN : '';
    if ( $zone && $token ) {
        wp_remote_post( "https://api.cloudflare.com/client/v4/zones/{$zone}/purge_cache", array(
            'headers'  => array(
                'Authorization' => "Bearer {$token}",
                'Content-Type'  => 'application/json',
            ),
            'body'     => wp_json_encode( array( 'files' => array_values( $urls ) ) ),
            'timeout'  => 10,
            'blocking' => false,
        ) );
    }
}

Rule of thumb: targeted purge for everyday edits, full purge only for big structural changes like a new menu or a theme update.

My overnight purge cancelled its own reason for being overnight

Purge only what changed, on every edit

add_action( 'save_post', function ( $post_id, $post ) {
    if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
        return;
    }
    if ( 'publish' !== $post->post_status ) {
        return;
    }

    $urls = array(
        get_permalink( $post_id ), // the page itself
        home_url( '/' ),           // the homepage, which likely lists it
    );

    // Its category or archive pages, if any.
    foreach ( get_the_category( $post_id ) as $cat ) {
        $urls[] = get_category_link( $cat );
    }

    Site_Cache_Purge::purge_urls( array_unique( $urls ) );
}, 10, 2 );

A nightly sweep as a safety net

No matter how careful your hooks are, something eventually slips past them: a bulk import, a quiet background update, an edit that does not fire save_post. A scheduled full purge overnight, when traffic is low, is cheap insurance that the whole site is never stale for long.

add_action( 'init', function () {
    if ( ! wp_next_scheduled( 'site_cache_nightly_purge' ) ) {
        // Runs daily; schedule it for a quiet hour on your server.
        wp_schedule_event( strtotime( 'tomorrow 4:00am' ), 'daily', 'site_cache_nightly_purge' );
    }
} );

add_action( 'site_cache_nightly_purge', array( 'Site_Cache_Purge', 'purge_all' ) );

Except WP-Cron will probably not run it

Read that schedule again, then read the reason I gave you for it: overnight, when traffic is low. Those two things cancel each other out, and it took me embarrassingly long to notice.

WordPress cron is not cron. wp_schedule_event does not hand anything to your operating system. It writes a due time into the database, and WordPress looks at that list when somebody loads a page. No page load, no cron. On a busy shop the difference never shows up, because there is always a visitor along in a minute. On a quiet business site at 4am there is nobody, so the sweep fires whenever the first human turns up the next morning, if one turns up at all. Your scheduled events screen will look perfectly healthy the entire time, which is the part that makes it expensive.

Take the job away from your visitors. Turn the built in behaviour off in wp-config.php:

// wp-config.php
define( 'DISABLE_WP_CRON', true );

Then let the machine do the scheduling:

# /etc/cron.d/wp-cron
*/5 * * * * siteuser cd /home/siteuser/htdocs/example.com && flock -n /tmp/wp-cron.lock wp cron event run --due-now --quiet

The flock is there so a slow run cannot overlap the next tick and purge twice at once. The username in the sixth column matters just as much: run it as the user that owns the site files, never as root. A cron job running as root writes root owned files into your cache directory, and PHP, which is not root, then cannot write there any more. That failure is silent too, and it is why the section further down called Assert on the result, not on the call ends with an ownership check.

Now 4am happens because it is 4am, not because somebody browsed.

Warm the cache so the first visitor is not the guinea pig

When you purge a page, the next person to visit it pays the rebuild cost. You can take that hit yourself instead, by quietly requesting the page right after you purge it, so it is already warm before a real customer arrives.

public static function warm( array $urls ) {
    foreach ( $urls as $url ) {
        wp_remote_get( $url, array(
            'timeout'  => 10,
            'blocking' => false, // we do not care about the response
        ) );
    }
}

Call warm() a second or two after a purge, a short scheduled event works well, so your visitors only ever land on already fast pages.

With one condition attached, and I will show you how to check it in a moment: warming only works if whatever is doing the warming can write to the cache directory. If it cannot, the request still succeeds, the page still renders, WordPress still returns a perfectly good 200, and nothing is stored. The next real visitor pays the rebuild cost exactly as if you had never warmed anything. A warm that cannot write is not a warm, it is an expensive page view.

Pro tip

After any purge, request the cleared URLs yourself before a real visitor does. The rebuild cost is the same either way: you pay it quietly in the background, and your customer lands on a page that is already warm.

A log of intentions is not a log of outcomes

A simple activity log

When a purge silently fails, you want to know before a customer does. Log every purge so you can see, at a glance, that it is firing.

private static function log( $message ) {
    $line = '[' . current_time( 'mysql' ) . '] ' . $message . "n";
    $file = WP_CONTENT_DIR . '/cache-purge.log';
    file_put_contents( $file, $line, FILE_APPEND | LOCK_EX );
}

Drop a call to log() at the top of each method while you are setting things up. Once you trust it, you can quiet it down.

Then read the next sentence twice, because this log is the thing that fooled me. It records that the purge fired. It does not record that anything was deleted. I have had a log file full of tidy timestamps, every line true, every line worthless, while the cache directory underneath sat completely untouched. A log of intentions is not a log of outcomes, and if the purge is fire and forget then the log is the only evidence you have, which makes it evidence of the wrong thing. Keep it, it is genuinely useful for working out when something happened. Just never accept it as proof that something happened.

Check the headers

The honest test is what the browser actually receives. Load a page twice and look at the response headers in your browser network tab. On the second load you want to see the page reported as a cache hit (Cloudflare shows cf-cache-status: HIT, Varnish setups usually add an X-Cache: HIT header). Then edit that page, reload, and confirm you get the new content immediately and the header flips back to a miss for one request, then a hit again. If that cycle works, your purge order is correct.

Assert on the result, not on the call

Headers tell you about one URL at a time, which is fine when you are debugging and useless as a standing check. The question you want answered automatically is not did my function return, it is has the thing gone. So count the cache before, count it after, and complain when the number refuses to move.

private static function count_cached_files( $dir ) {
    if ( ! is_dir( $dir ) ) {
        return -1; // a missing path is not an empty cache; the caller decides what it means
    }

    $files = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS )
    );

    return iterator_count( $files );
}

public static function purge_all_verified() {
    $dir    = WP_CONTENT_DIR . '/cache'; // the directory your plugin really writes to
    $before = self::count_cached_files( $dir );

    self::purge_all();
    clearstatcache();

    $after = self::count_cached_files( $dir );

    if ( -1 === $before ) {
        self::log( "CACHE DIRECTORY MISSING BEFORE THE PURGE, SO NOTHING WAS EVER BEING CACHED: {$dir}" );
        return;
    }

    if ( -1 === $after ) {
        // The purge took the directory itself. Nothing is cached, which is the point.
        return;
    }

    if ( $before > 0 && $after >= $before ) {
        self::log( "PURGE REPORTED SUCCESS AND REMOVED NOTHING: {$before} files before, {$after} after, in {$dir}" );
    }
}

Two details in there are load bearing. The first is that the directory has to be the one your plugin actually uses, which is not always the one the documentation names. Go and look with ls before you trust the constant. The second is the -1, and which side of the purge it turns up on. A path that does not exist and a cache that is genuinely empty both count zero files, so a missing directory gets a value of its own rather than being quietly folded in with zero. Before the purge, -1 is the alarm: there was nothing there to clear, so whatever you believed you were caching, you were not, and a check that treats a missing directory as an empty one will cheerfully congratulate you forever on a cache that never existed. After the purge, -1 usually means the opposite, because plenty of page caches remove the directory itself on a full clear rather than emptying it and leaving the shell behind. Same value, same function, and the meaning flips depending on when you read it. I had both sides folded into one branch to begin with, which meant the runs where the purge worked perfectly were the runs it shouted at me hardest. Shouting the wrong thing at yourself is how you spend an afternoon fixing something that was never broken.

Then call purge_all_verified() from the nightly job instead of purge_all(), and make both of those log lines loud enough that you actually see them. A line a year is a bargain against a week of measuring the wrong thing.

Two more checks that cost nothing. Look at modification times after a warm: if the newest file in the cache directory is older than your last purge, nothing is being rebuilt, whatever the plugin dashboard is claiming. And confirm that the user PHP runs as can write to that directory at all.

# who owns the cache directory?
ls -ld wp-content/cache
# drwxr-xr-x 12 root root ... means PHP is locked out

# can the web user actually write there?
sudo -u www-data test -w wp-content/cache && echo writable || echo NOT WRITABLE

# is anything landing in there after a warm?
find wp-content/cache -type f -newermt '-5 minutes' | wc -l

That last check is not hypothetical. I lost most of a day to a cache directory that had quietly gone root owned, because one maintenance command had been run as root and every file it touched inherited that. The preloader ran on schedule. It reported no errors, because from where it stood there were none. It wrote zero pages for seven and a half minutes while real visitors were served uncached PHP, and the only way to see it was to go and look at the disk. Three seconds of ls would have saved the day I spent tuning things that were never the problem.

The general rule underneath all of this: a cache operation that cannot fail loudly will eventually fail quietly, and quiet failure in a cache does not look like a bug. It looks like a site that is a bit slow today.

What the numbers actually say

This whole article is a claim about speed, so here is the evidence, from the platform I mentioned at the top. Repeated PageSpeed Insights runs against the live homepage, on a cache I had checked was genuinely warm first with 832 pages preloaded, spaced about two minutes apart. The spacing is not fussiness and I will come back to it.

On desktop the homepage scores 98, and across repeated runs it never left 97 to 99. Largest Contentful Paint has a median of 978 ms, so the main content is on screen in just under a second. First Contentful Paint is 361 ms and it barely breathes: 11 ms of spread across five runs. Speed Index is 784 ms. Cumulative Layout Shift is 0.001 on desktop and 0.000 on mobile, which is a formal way of saying nothing jumps about while you are reading. The homepage HTML is 330 KB, compressing to 76 KB over the wire, and that 76 KB is the thing a first time visitor is actually waiting for before anything can paint at all.

There is one part of that page caching never touched, and it shows you the edge of what caching can do for you. The change that finally moved the paint was changing the format of the hero image. Caching had nothing to do with it. The bytes were already arriving fast, the hero was coming back off the edge, near the visitor, without my server being asked anything at all. Varnish and Redis were never in that path in the first place: one holds finished HTML, the other holds database answers, and neither of them has ever served an image. The time was going on decoding it, on a processor Google throttles to a quarter of its speed, because I had served the hero as AVIF on the reasoning that AVIF files are smaller and smaller is faster. It saved about 19 KB of transfer and gave it back many times over in decode. Caching makes the bytes arrive quickly. It cannot make a slow phone do less work with them once they land.

One last thing about the two minute spacing, because measurement lies to you in the same style as everything else in this article. PageSpeed Insights caches its results. Four back to back calls once handed me byte identical output, down to the millisecond, on all four. That looks like beautiful stability. It is one sample repeated four times. Zero spread is the tell: if your numbers do not move at all between runs, you are not measuring, you are re-reading. Space the samples out, keep every run, and quote the median rather than the one you liked best.