Page caching, CDNs and cache invalidation

Understand how full-page caches and CDNs serve WordPress, write code that stays cache-friendly, and purge the right URLs when content changes.

  • Intermediate
  • 10 min read
  • Updated 17 Oct 2026

What you will learn

  • Make pages cache-friendly
  • Purge caches on content changes
  • Explain where page caches sit in front of WordPress
  • Send correct cache headers for private and dynamic responses

Before you start

The object cache saves database work inside a request. A page cache goes further: it stores the finished HTML and serves it without running WordPress at all. For most anonymous traffic, that is the single biggest change you can make to speed and capacity. It also changes how you must write code, because PHP no longer runs for every visitor.

HTTP caching itself (Cache-Control, ETags, revalidation) belongs to its own course. This lesson covers how it meets WordPress.

Where page caches sit

A request can be answered at several layers before it reaches PHP:

LayerExampleWhat it stores
BrowserThe visitor's own cacheAssets, sometimes pages
CDNAn edge network close to the visitorAssets and, if configured, full pages
Reverse proxyVarnish or an Nginx cache on your serversFull pages
WordPress drop-inadvanced-cache.phpFull pages, served early in PHP
Object cacheRedis or MemcachedData, not pages

The further out a page is served, the less work your servers do. A CDN hit never touches your servers. A reverse proxy hit never starts PHP. A drop-in hit starts PHP but stops before plugins and themes load.

The advanced-cache.php drop-in

WordPress has a hook for PHP-level page caching. When wp-config.php contains:

PHP
define( 'WP_CACHE', true );

WordPress loads wp-content/advanced-cache.php very early in the bootstrap, before plugins. A caching plugin places its own file there and serves a stored page if one exists. You do not write this file yourself; you only need to know it exists when you debug why a change is not showing.

What makes a page uncacheable

A page is safe to cache when every anonymous visitor should see the same HTML. Caches decide this mostly from the URL, the method and cookies.

Logged-in users and cookies

WordPress sets cookies such as wordpress_logged_in_* for logged-in users, comment_author_* after someone comments, and wp-postpass_* for password-protected posts. Well-configured caches bypass the cache when these cookies are present, because the page differs per person (the admin bar, a pre-filled comment form, unlocked content).

The common mistake is a plugin or theme that sets its own cookie for every visitor, for example to remember a currency or a dismissed banner. If the cache is configured to bypass on unknown cookies, every request now misses. If it ignores the cookie, visitors can get each other's version. Neither is good.

Keep per-visitor preferences in the browser (localStorage or a cookie read only by JavaScript) and adjust the page client-side, or use separate URLs for real variations such as languages.

Per-visitor output in PHP

Anything that depends on the current visitor breaks when the HTML is shared:

  • Greeting the user by name, or a cart count, rendered in the template.
  • Random content picked with rand() in PHP: the first visitor's choice is served to everyone.
  • Geolocation or A/B decisions made on the server for each request.
  • Reading $_GET parameters that the cache does not include in its key.

Render the shared shell in PHP and fetch the personal part from the REST API with JavaScript after load.

Nonces in cached pages

Nonces are tied to a user and a time window, and expire after a day or so. A cached page can keep serving an expired nonce long after it was generated. For anonymous visitors, nonces in public forms rarely protect anything anyway, but an expired one makes the form fail.

If a cached page needs a fresh nonce, request it with JavaScript from a small endpoint that is never cached, or keep cache lifetimes shorter than the nonce lifetime.

Sending the right headers

Responses that must never be cached should say so. WordPress already sends no-cache headers on the admin and for logged-in users in many places. For your own dynamic output, call nocache_headers() before any output:

PHP
// In a plugin: a page template that shows the current user's orders.
add_action( 'template_redirect', 'myplugin_no_cache_account_page' );

function myplugin_no_cache_account_page(): void {
    if ( is_page( 'my-account' ) ) {
        nocache_headers();
    }
}

For REST endpoints that return personal data, set the header on the response:

PHP
function myplugin_rest_get_orders( WP_REST_Request $request ): WP_REST_Response {
    // myplugin_get_orders_for_user() is your own data function.
    $orders   = myplugin_get_orders_for_user( get_current_user_id() );
    $response = new WP_REST_Response( $orders, 200 );
    $response->header( 'Cache-Control', 'private, no-store' );

    return $response;
}

You can also add headers to public pages with the wp_headers filter, for example to let a CDN cache HTML for a short time:

PHP
add_filter( 'wp_headers', 'myplugin_public_cache_headers' );

function myplugin_public_cache_headers( array $headers ): array {
    if ( ! is_user_logged_in() && ! is_admin() ) {
        $headers['Cache-Control'] = 'public, max-age=300';
    }

    return $headers;
}

Only add public cache headers once you are sure nothing on the page varies by visitor. A wrong public header on a page with personal data is a privacy incident, not a performance bug.

Purging when content changes

A page cache with a long lifetime is only acceptable if it is cleared when content changes. Caching plugins and managed platforms usually purge the post URL, the home page and archives when a post is updated. When you build your own integration, or add content the cache does not know about, you hook in yourself.

Which URLs change

Publishing or editing one post can change many pages:

  • The post's permalink.
  • The home page and the blog page.
  • Category, tag and custom taxonomy archives the post belongs to.
  • The author archive.
  • Feeds and the sitemap.
  • Any page that lists the post, such as a "latest news" block on another page.

Hooking into post changes

clean_post_cache fires whenever core clears a post's cache, which covers updates, status changes and deletions. transition_post_status tells you when a post moves between statuses, such as from draft to publish.

This example collects affected URLs and sends a purge request to a reverse proxy that accepts the PURGE method. Your proxy or CDN will have its own purge mechanism; the WordPress side looks the same.

PHP
<?php
/**
 * Plugin Name: My Plugin Cache Purge
 */

defined( 'ABSPATH' ) || exit;

add_action( 'transition_post_status', 'myplugin_purge_on_status_change', 10, 3 );

function myplugin_purge_on_status_change( string $new_status, string $old_status, WP_Post $post ): void {
    // Only public content that is, or was, visible.
    if ( 'publish' !== $new_status && 'publish' !== $old_status ) {
        return;
    }

    if ( wp_is_post_revision( $post ) || ! is_post_type_viewable( $post->post_type ) ) {
        return;
    }

    myplugin_purge_urls( myplugin_urls_for_post( $post ) );
}

function myplugin_urls_for_post( WP_Post $post ): array {
    $urls = array(
        get_permalink( $post ),
        home_url( '/' ),
        get_author_posts_url( (int) $post->post_author ),
        get_post_type_archive_link( $post->post_type ),
    );

    $taxonomies = get_object_taxonomies( $post->post_type );
    $terms      = wp_get_post_terms( $post->ID, $taxonomies );

    if ( ! is_wp_error( $terms ) ) {
        foreach ( $terms as $term ) {
            $urls[] = get_term_link( $term );
        }
    }

    // Remove false values and errors, then duplicates.
    $urls = array_filter( $urls, 'is_string' );

    return array_unique( $urls );
}

function myplugin_purge_urls( array $urls ): void {
    foreach ( $urls as $url ) {
        wp_remote_request(
            $url,
            array(
                'method'   => 'PURGE',
                'timeout'  => 2,
                'blocking' => false,
            )
        );
    }
}

Notes on this code:

  • 'blocking' => false stops editors from waiting for every purge before the save completes.
  • The status check covers unpublishing too: a post moving from publish to draft must disappear from cached archives.
  • get_post_type_archive_link() returns false for post types without an archive, which is why the list is filtered.

Changes that are not post saves also affect cached pages: menus, widgets, theme settings, Customizer or Site Editor changes, and plugin settings. Many setups simply purge everything on those, because they change every page.

Purge everything sparingly

A full purge sends every visitor to PHP at once until the cache refills. On a busy site that load spike is real. Prefer targeted purges for content changes and keep full purges for deployments and global design changes.

Assets and CDNs

Static files are the easy win. CSS, JavaScript, fonts and images can be cached for a long time if their URL changes when the file changes. WordPress helps here: the $ver argument of wp_enqueue_script() and wp_enqueue_style() adds ?ver= to the URL. Set it to your plugin version or the file's modification time, and you can safely give assets a long cache lifetime.

PHP
wp_enqueue_style(
    'mytheme-main',
    get_theme_file_uri( 'assets/main.css' ),
    array(),
    (string) filemtime( get_theme_file_path( 'assets/main.css' ) )
);

Uploaded media rarely changes at the same URL, so it caches well. If you serve uploads from a CDN hostname, the wp_get_attachment_url filter or the CDN's own integration rewrites the URLs.

Testing a cached site

  • Check response headers in the browser's network panel. Most caches add a header showing a hit or a miss.
  • Test logged out, in a private window. Logged-in testing hides almost every caching bug.
  • Update a post and confirm the permalink and its archives change within the expected time.
  • Load a page twice with different query strings and cookies to see what the cache key includes.

Summary

  • Page caches store finished HTML at the CDN, a reverse proxy or the advanced-cache.php drop-in enabled by WP_CACHE.
  • Pages are cacheable only when every anonymous visitor should see the same HTML; per-visitor cookies and PHP output break that.
  • Load personal content with JavaScript and the REST API, and send nocache_headers() or Cache-Control: private for private responses.
  • Purge the post URL, home page, archives and feeds on transition_post_status or clean_post_cache, and avoid frequent full purges.
  • Version asset URLs with the $ver argument so static files can be cached for a long time.

Further reading