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:
| Layer | Example | What it stores |
|---|---|---|
| Browser | The visitor's own cache | Assets, sometimes pages |
| CDN | An edge network close to the visitor | Assets and, if configured, full pages |
| Reverse proxy | Varnish or an Nginx cache on your servers | Full pages |
| WordPress drop-in | advanced-cache.php | Full pages, served early in PHP |
| Object cache | Redis or Memcached | Data, 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:
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
$_GETparameters 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:
// 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:
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:
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
/**
* 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' => falsestops 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()returnsfalsefor 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.
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.phpdrop-in enabled byWP_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()orCache-Control: privatefor private responses. - Purge the post URL, home page, archives and feeds on
transition_post_statusorclean_post_cache, and avoid frequent full purges. - Version asset URLs with the
$verargument so static files can be cached for a long time.