A heavy, database-driven WordPress site can load as fast as a flat HTML file by stacking four cache layers, each with one job. The real trick is not adding caches: it is purging them in the right order, from the inside out, so no outer layer ever re-caches a stale page. Get the purge order right and your editors see changes immediately, your visitors get pages instantly, and your server stays calm under load. This guide walks through the exact setup, step by step, in code you can copy.
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, ampy.se, 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.
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 (FlyingPress or similar)
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.
Why order is the whole trick
On ampy.se 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.
Building the purge manager, step by step
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' );
} 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.
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.
Wiring it into WordPress
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' ) ); 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.
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.
Prove it works, do not just trust it
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.
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.
The payoff, in business terms
Here is what this actually buys you, past the jargon.
- 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.