In 2026, the performance gap between a “standard” WordPress site and an “enterprise” WordPress site is defined by cache intelligence. While basic page caching was sufficient a decade ago, modern web applications require sophisticated multi-layer caching strategies that understand what to cache, where to cache it, and precisely when to invalidate it.
Ten years ago, caching was simple: you saved a page as an HTML file and served it until it expired. In 2026, that all-or-nothing approach is no longer sufficient. Modern websites are hybrid entities - part static, part dynamic, and served globally to users with varying connection speeds and devices. To hit 100/100 Core Web Vitals while serving personalized content, you need a strategy that optimizes every layer of the delivery stack.
The practical architecture is layered: browser cache, CDN edge, full-page cache, fragment cache, object cache and database hygiene working together instead of competing.
How three-layer edge caching works for WordPress
In 2026, your origin server almost never sees a visitor request directly. Modern WordPress implementations follow a sophisticated triple-layer caching strategy that distributes content across the globe.
Layer 1: Static edge cache (CDN)
HTML, images, CSS, and JavaScript are stored at the network edge through services like Cloudflare, Bunny.net, or Fastly. This provides the critical sub-50ms Time to First Byte (TTFB) that users expect.
Key Configuration Settings:
- Cache Everything: Cache HTML at the edge, not just static assets
- Edge TTL: Set appropriate cache lifetimes (1 hour for dynamic, 1 year for static)
- Browser TTL: Control client-side caching headers
- Always Online: Serve stale content if origin is unreachable
Cloudflare Page Rules Example:
*example.com/*
Cache Level: Cache Everything
Edge Cache TTL: 2 hours
Browser Cache TTL: 30 minutes
Always Online: OnLayer 2: API response caching
For headless or decoupled WordPress setups, API responses are cached separately from HTML:
- REST API endpoints cached with short TTL (5-15 minutes)
- GraphQL queries cached based on complexity and data volatility
- Webhook-triggered cache purging for instant updates
Benefits:
- Reduces database load for JavaScript-driven frontends
- Enables faster mobile app experiences
- Supports real-time features with cached fallbacks
Layer 3: Cache locking and thundering herd protection
When a cached item expires, traditional setups allow multiple simultaneous requests to hit the origin server. In 2026, we implement cache locking:
- Only the first request regenerates the cached content
- Subsequent requests are served a slightly stale version
- Once regeneration completes, all users receive fresh content
Implementation with Cloudflare Workers:
// Cache locking pattern
async function handleRequest(request) {
const cache = caches.default;
const cacheKey = new Request(request.url);
let response = await cache.match(cacheKey);
if (!response) {
// Check for regeneration lock
const lockKey = `lock:${request.url}`;
const lock = await CACHE_LOCKS.get(lockKey);
if (lock) {
// Serve stale while regenerating
response = await cache.match(`${cacheKey}:stale`);
} else {
// Acquire lock and regenerate
await CACHE_LOCKS.put(lockKey, 'locked', {expirationTtl: 30});
response = await fetchOrigin(request);
await cache.put(cacheKey, response.clone());
await cache.put(`${cacheKey}:stale`, response.clone());
}
}
return response;
}Full page caching with Nginx FastCGI and Varnish
Edge networks deliver static assets and public HTML rapidly, but origin architectures still require high-efficiency full-page caching before PHP execution begins. Nginx FastCGI cache stores rendered HTML responses in shared memory zones and on local storage, bypassing PHP-FPM workers completely for anonymous visitors:
# FastCGI cache configuration in nginx.conf
fastcgi_cache_path /var/run/nginx-cache levels=1:2 keys_zone=WORDPRESS:256m inactive=1d max_size=4g;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
# Purge location using fastcgi_cache_purge
location ~ /purge(/.*) {
allow 127.0.0.1;
allow 10.0.0.0/8;
deny all;
fastcgi_cache_purge WORDPRESS "$scheme$request_method$host$1";
}When editors update content, a purge plugin sends an HTTP PURGE request to the local Nginx endpoint, and the entry for that cache key is removed. The nginx FastCGI module documentation says cache file names are the MD5 of the key, and it lists fastcgi_cache_purge as part of the commercial subscription, so open source Nginx builds rely on the third-party ngx_cache_purge module for the same job. The same page documents two directives worth enabling on any busy WordPress origin: fastcgi_cache_lock, which lets only one request at a time populate a new cache element, and fastcgi_cache_use_stale, which serves a stale response when the FastCGI backend errors. In multi-server environments with Varnish, PURGE requests or BAN expressions clear entries by URL pattern or by tag headers emitted by WordPress plugins.
How to purge WordPress cache by tag
The biggest problem with enterprise caching has always been “The Purge Problem.” You update a typo on the homepage, and the entire site cache gets deleted, causing a massive performance hit and origin server overload.
In 2026, we solve this with Cache Tagging.
How cache tagging works
Instead of treating cached content as isolated files, we tag each cached item with metadata about its dependencies:
Homepage: tags=["home", "post_123", "post_124", "category_news"]
Blog Post: tags=["post_125", "category_tutorials", "author_john"]
Category Page: tags=["category_tutorials", "post_125", "post_126"]When you update “Post A,” only the items tagged with post_a are purged:
- The post itself
- Its category archive pages
- Related posts widgets
- Author archive pages
- Homepage (if featured)
The rest of your 10,000 pages remain cached and fast.
Implementing cache tags in WordPress
With Cloudflare:
// Add cache tags to Cloudflare
function add_cache_tags_header() {
if (is_single()) {
$tags = [
'post_' . get_the_ID(),
'author_' . get_the_author_meta('ID'),
];
// Add category tags
$categories = get_the_category();
foreach ($categories as $cat) {
$tags[] = 'category_' . $cat->slug;
}
header('Cache-Tag: ' . implode(',', $tags));
}
}
add_action('template_redirect', 'add_cache_tags_header');Purge by Tag:
// Purge specific tags when post is updated
function purge_post_cache_tags($post_id) {
$tags = [
'post_' . $post_id,
'author_' . get_post_field('post_author', $post_id),
];
// Cloudflare API purge by tag
$api = new Cloudflare\API\Client($email, $api_key);
$api->zones()->cachePurgeTags($zone_id, $tags);
}
add_action('save_post', 'purge_post_cache_tags');Which cache tags to purge when a post changes
Smart Tag Hierarchy:
post_{id}: Individual post contentcategory_{slug}: Category archive pagesauthor_{id}: Author archive pagestaxonomy_{name}_{term}: Custom taxonomy pageswidget_{id}: Dynamic widget contentglobal: Site-wide elements (header, footer, menus)
Conditional Purging:
// Only purge what changed
function smart_cache_purge($post_id) {
$post = get_post($post_id);
$tags = ['post_' . $post_id];
// If featured image changed, purge image-related caches
if (did_post_thumbnail_change($post_id)) {
$tags[] = 'featured_images';
}
// If post status changed, purge archive listings
if ($post->post_status !== get_previous_post_status($post_id)) {
$tags[] = 'category_' . get_primary_category($post_id);
$tags[] = 'author_' . $post->post_author;
}
purge_cache_tags($tags);
}Redis object cache for WordPress in 2026
The database is the bottleneck of WordPress. Every page load triggers dozens of SQL queries to fetch posts, metadata, options, and user data. Object caching stores these query results in memory, eliminating redundant database lookups.
Why Redis 8+ in 2026
The Redis Open Source 8.0 release notes (May 2025) state that Redis Search, JSON, time series and five probabilistic data structures, previously separate modules, are now part of Redis itself. For WordPress, that means one server can hold the object cache and also the data used by search or metrics features:
Redis 8 Features:
- RedisJSON: Store and query complex JSON objects
- RedisSearch: Full-text search within cached data
- RedisTimeSeries: Track cache performance metrics
- Persistence options: RDB and AOF for data durability
Redis vs Memcached (2026 Comparison):
| Feature | Redis 8+ | Memcached |
|---|---|---|
| Data Types | 8+ (strings, hashes, lists, sets, sorted sets, JSON, streams) | 1 (strings) |
| Persistence | RDB + AOF | None |
| Replication | Master-slave + Cluster | None |
| Memory Efficiency | Better with compression | Good |
| Tag invalidation | Manual (for example sets of keys) | Manual implementation |
| Query Capabilities | RedisSearch | None |
Redis drop-in versus Memcached and object cache mechanics
WordPress core provides a pluggable caching interface through wp-content/object-cache.php. Without an object cache drop-in, WordPress defaults to an in-memory runtime cache for the lifetime of a single PHP request. With a persistent drop-in (for example the one installed by the Redis Object Cache plugin, or a Memcached drop-in), calls like wp_cache_set(), wp_cache_get(), wp_cache_delete(), and wp_cache_add() are served by the Redis or Memcached server and survive between requests. The WP_Object_Cache reference puts the default plainly: “By default, the object cache is non-persistent”, so data lives “in memory only and only for the duration of the request”.
Developers frequently confuse transients (set_transient(), get_transient()) with direct object cache calls (wp_cache_set(), wp_cache_get()):
- When an external object cache drop-in is active,
set_transient()maps directly towp_cache_set()under thetransientgroup, completely bypassing thewp_optionstable. - When no persistent drop-in exists, transients are written to
wp_optionsas rows (_transient_*and_transient_timeout_*). In core’sset_transient()code, a transient saved with an expiration gets a timeout row and is not autoloaded, while a transient saved without one is autoloaded on every request, so expiry-less transients quietly grow the autoloaded options. - The Transients API handbook limits the key to 172 characters and quotes Ryan McCue: “transient expiration times are a maximum time. There is no minimum age.” Code must always handle a miss, because Redis or Memcached can evict the value before its TTL.
- Direct
wp_cache_*calls allow custom cache groups and granular invalidation.
// Storing complex computed data in a dedicated cache group
$cache_key = 'user_order_summary_' . $user_id;
$order_summary = wp_cache_get($cache_key, 'orders_analytics');
if (false === $order_summary) {
$order_summary = compute_heavy_user_order_metrics($user_id);
// Cache for 15 minutes in the orders_analytics group
wp_cache_set($cache_key, $order_summary, 'orders_analytics', 900);
}Cache groups and non-persistent groups
By default, an external object cache persists all groups across HTTP requests. However, highly volatile or request-specific data should never pollute external RAM or incur round-trip latency. WordPress core provides wp_cache_add_non_persistent_groups() to keep specific groups strictly in PHP process memory:
// Register non-persistent groups in an early hook or mu-plugin
function register_custom_cache_groups() {
// Keep temporary session flags in PHP process RAM only
wp_cache_add_non_persistent_groups([
'runtime_request_flags',
'transient_session_tokens',
]);
}
add_action('init', 'register_custom_cache_groups');For persistent groups across multisite networks, wp_cache_add_global_groups() ensures keys remain shared across network blog IDs instead of receiving site-specific prefixes.
WordPress object cache implementation
Basic Redis Configuration (wp-config.php):
// Enable object cache
define('WP_CACHE', true);
// Redis settings
define('WP_REDIS_HOST', '127.0.0.1');
define('WP_REDIS_PORT', 6379);
define('WP_REDIS_DATABASE', 0);
define('WP_REDIS_TIMEOUT', 1);
define('WP_REDIS_READ_TIMEOUT', 1);
// Advanced settings
define('WP_REDIS_DISABLE_METRICS', false);
define('WP_REDIS_DISABLE_BANNERS', true);
define('WP_REDIS_PREFIX', 'wp_');Micro-caching Strategy: Even short-lived cache entries provide massive benefits:
// Cache recent comments for 30 seconds
function get_cached_recent_comments($limit = 5) {
$cache_key = 'recent_comments_' . $limit;
$comments = wp_cache_get($cache_key);
if (false === $comments) {
$comments = get_comments([
'number' => $limit,
'status' => 'approve',
]);
wp_cache_set($cache_key, $comments, '', 30); // 30 seconds
}
return $comments;
}Relational Object Cache: Modern implementations understand relationships between objects:
// Automatic relationship caching
class RelationalObjectCache {
public function get_post_with_meta($post_id) {
$cache_key = "post_with_meta:{$post_id}";
$data = wp_cache_get($cache_key);
if (false === $data) {
$post = get_post($post_id);
$meta = get_post_meta($post_id);
$author = get_userdata($post->post_author);
$data = [
'post' => $post,
'meta' => $meta,
'author' => $author,
];
// Cache with relationship tags
wp_cache_set($cache_key, $data, '', 3600);
wp_cache_add_relationship($cache_key, "post:{$post_id}");
wp_cache_add_relationship($cache_key, "user:{$post->post_author}");
}
return $data;
}
}Redis monitoring and optimization
Cache Hit Rate Monitoring:
# Redis CLI commands for monitoring
redis-cli info stats | grep keyspace
redis-cli info memory
redis-cli slowlog get 10WordPress Admin Integration:
// Add cache stats to admin bar
function add_cache_stats_to_admin_bar($wp_admin_bar) {
$stats = wp_cache_get_stats();
$hit_rate = ($stats['hits'] / ($stats['hits'] + $stats['misses'])) * 100;
$wp_admin_bar->add_node([
'id' => 'cache_stats',
'title' => sprintf('Cache: %.1f%% hit rate', $hit_rate),
'href' => admin_url('tools.php?page=redis-cache'),
]);
}How to cache dynamic WordPress content with ESI
How do you cache a page that says “Hello, [User Name]” or shows a shopping cart total? In 2026, we use Edge Side Includes (ESI) or Client-Side Hydration to combine static and dynamic content.
Why full-page caching breaks personalization
Traditional full-page caching breaks personalization:
- User greetings become static (“Hello, Guest” for everyone)
- Shopping carts show stale or empty states
- Admin bars appear for all users
- Dynamic pricing doesn’t update
How fragment caching fills placeholders at the edge
The main page is 100% cached as static HTML. Dynamic elements are placeholders filled in at the edge or via JavaScript:
ESI Implementation:
<!-- Static cached content -->
<h1>Welcome to Our Store</h1>
<!-- Dynamic fragment -->
<esi:include src="/esi/cart-count.php" />
<!-- Static content continues -->
<p>Check out our latest products...</p>
<!-- Personalized greeting -->
<esi:include src="/esi/user-greeting.php" />Client-Side Hydration (Alternative):
<!-- Static placeholder -->
<div id="cart-count" data-dynamic-url="/api/cart-count">
<span class="placeholder">--</span>
</div>
<script>
// Hydrate on page load
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-dynamic-url]').forEach(el => {
fetch(el.dataset.dynamicUrl)
.then(r => r.text())
.then(html => el.innerHTML = html);
});
});
</script>WordPress fragment caching implementation
Varnish ESI with WordPress:
// Mark dynamic content with ESI tags
function esi_dynamic_fragment($content, $callback, $args = []) {
$esi_url = add_query_arg([
'esi_action' => $callback,
'esi_args' => base64_encode(json_encode($args)),
], site_url('/esi-endpoint/'));
return "<esi:include src=\"{$esi_url}\" />";
}
// Usage in templates
echo esi_dynamic_fragment('user_greeting', ['user_id' => get_current_user_id()]);WooCommerce cart and checkout fragment caching bypass:
In standard WooCommerce stores, wc-ajax=get_refreshed_fragments fires on every uncached page load to update cart totals and item counts. Each of those requests runs PHP on the origin. Dequeuing the fragments script on pages that do not show a cart, and replacing it with ESI or client-side storage where a cart is shown, removes that request:
// Dequeue cart fragments on non-shop and static content pages
function bypass_woocommerce_cart_fragments() {
if (is_front_page() || is_single() || is_page()) {
if (!is_woocommerce() && !is_cart() && !is_checkout()) {
wp_dequeue_script('wc-cart-fragments');
}
}
}
add_action('wp_enqueue_scripts', 'bypass_woocommerce_cart_fragments', 20);For checkout, cart, and account pages, page caching must be bypassed completely at both the edge and Nginx layers using cookie inspection:
- When the
woocommerce_items_in_cartcookie exists, edge caches bypass full-page cache lookups and proxy directly to the origin. - Sensitive sessions (
woocommerce_cart_hash,wp_woocommerce_session_) trigger immediateCache-Control: private, no-cache, no-store, must-revalidateresponse headers. - Cart fragments inside cached catalog pages are refreshed via browser
localStorageandsessionStorage, updating the mini-cart DOM without firing origin PHP processes.
Cart fragments and checkout exclusions belong with store-side latency work, not only with ESI wiring: see WooCommerce performance optimization 2026 and the confidential WooCommerce performance recovery case study.
How to use Speculation Rules API prefetching in WordPress
In 2026, caching isn’t just about what happened - it’s about what will happen. Using the Speculation Rules API, your WordPress site can predict and preload content before users click.
How speculative prefetching works
The eagerness setting decides when the browser acts. According to Chrome’s prerender documentation, on desktop eager speculates after the pointer rests on a link for 10 milliseconds, moderate after 200 milliseconds (or on pointerdown if sooner), and conservative on pointer or touch down. With prerender, the browser renders the target page in the background, so the next click feels instantaneous.
WordPress 6.8 ships this in core. The speculative loading dev note says the default “effectively leads to a mode of prefetch and an eagerness of conservative”, that it is off when “a user is logged in or when a site has pretty permalinks disabled”, and that URLs with query parameters are excluded. The wp_speculation_rules_configuration filter changes mode and eagerness, and wp_speculation_rules_href_exclude_paths adds exclusions such as cart and checkout paths.
Implementation:
<script type="speculationrules">
{
"prerender": [{
"source": "list",
"urls": [
"/about/",
"/services/",
"/contact/"
]
}, {
"source": "document",
"where": {
"href_matches": "/blog/*",
"selector_matches": "a[rel='prerender']"
}
}]
}
</script>How to add speculation rules in WordPress with PHP
Smart Prefetching Based on Content:
// Add speculation rules based on current page
function add_speculation_rules() {
if (!is_singular('post')) {
return;
}
// Get related posts for prefetching
$related = get_related_posts(get_the_ID(), 3);
$urls = array_map(function($post) {
return get_permalink($post->ID);
}, $related);
// Add next/prev posts
if ($next = get_next_post()) {
$urls[] = get_permalink($next->ID);
}
$rules = [
'prerender' => [[
'source' => 'list',
'urls' => $urls,
]],
];
echo '<script type="speculationrules">' .
json_encode($rules) .
'</script>';
}
add_action('wp_head', 'add_speculation_rules');Hover-Based Prefetching (Fallback):
// Prefetch on link hover
let prefetchTimeout;
document.addEventListener('mouseover', (e) => {
const link = e.target.closest('a');
if (!link || link.hostname !== location.hostname) return;
prefetchTimeout = setTimeout(() => {
const prefetchLink = document.createElement('link');
prefetchLink.rel = 'prefetch';
prefetchLink.href = link.href;
document.head.appendChild(prefetchLink);
}, 100);
});
document.addEventListener('mouseout', () => {
clearTimeout(prefetchTimeout);
});Stale-while-revalidate cache headers for WordPress
Users hate loading spinners. SWR allows the browser to serve “stale” (slightly old) content immediately while fetching the fresh version in the background.
How SWR works
- User requests a page
- Browser checks cache
- If cached content exists (even if expired), serve it immediately
- In the background, fetch fresh content from origin
- Update cache and optionally refresh the page content
Cache-Control Header:
Cache-Control: max-age=60, stale-while-revalidate=300This means:
- Content is considered fresh for 60 seconds
- After 60 seconds, serve stale content for up to 300 seconds while revalidating
- After 360 seconds total, require fresh content
How to implement SWR in WordPress
Service Worker Implementation:
// service-worker.js
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
// Return cached version immediately (even if stale)
const fetchPromise = fetch(event.request).then((networkResponse) => {
caches.open('dynamic').then((cache) => {
cache.put(event.request, networkResponse.clone());
});
return networkResponse;
});
// Return stale or wait for fresh
return response || fetchPromise;
})
);
});Cloudflare SWR Configuration:
// Add SWR headers to dynamic content
function add_swr_headers($headers) {
if (is_singular() && !is_user_logged_in()) {
$headers['Cache-Control'] = 'public, max-age=60, stale-while-revalidate=300';
}
return $headers;
}
add_filter('wp_headers', 'add_swr_headers');Which Cache-Control directives WordPress pages need at the edge
Stale-while-revalidate is one directive among several, and the others decide whether a page may be cached at all. MDN’s Cache-Control reference defines the ones that matter for WordPress:
| Directive | MDN definition (short) | WordPress use |
|---|---|---|
s-maxage | Freshness in a shared cache; overrides max-age and Expires there | Long edge TTL for anonymous HTML while browsers keep a short max-age |
stale-while-revalidate | The cache may reuse a stale response while it revalidates | Archive and article pages that change a few times a day |
stale-if-error | The cache may reuse a stale response when the upstream returns 500, 502, 503 or 504 | Keeps pages online during a PHP-FPM or database outage |
private | Only a private cache, such as the browser, may store the response | Account pages, logged-in views |
no-store | No cache of any kind may store the response | Cart, checkout, password reset |
Two details from Cloudflare’s cache-control documentation change how these combine. First, Cloudflare treats s-maxage as a rule that a shared cache must revalidate with the origin before serving stale content, so s-maxage disables stale-while-revalidate there. If you want background revalidation at the Cloudflare edge, send max-age with stale-while-revalidate and control the edge TTL with a Cache Rule instead. Second, with Origin Cache Control enabled and the default cache level, a response that carries Set-Cookie is not cached. WordPress sets cookies for logged-in users and WooCommerce sets them for carts, so a stray Set-Cookie on a public page quietly turns its edge cache off.
The starting point matters too. Cloudflare’s default cache behavior page states: “The Cloudflare CDN does not cache HTML or JSON by default.” It caches by file extension, not by MIME type. Full-page edge caching for WordPress therefore always needs an explicit Cache Rule that marks HTML as eligible, with bypass conditions for wp-admin, wp-login.php, the REST API where it returns private data, and the wordpress_logged_in_ and woocommerce_items_in_cart cookies.
How to monitor and debug WordPress cache
Effective caching requires visibility into what’s happening. In 2026, we use sophisticated monitoring tools to ensure cache effectiveness.
How to add X-Cache-Status debug headers
Add debug headers to track cache behavior:
function add_cache_debug_headers() {
if (!current_user_can('manage_options')) {
return;
}
header('X-Cache-Status: ' . (did_cache_hit() ? 'HIT' : 'MISS'));
header('X-Cache-TTL: ' . get_cache_remaining_ttl());
header('X-Cache-Tags: ' . implode(', ', get_current_page_cache_tags()));
}
add_action('template_redirect', 'add_cache_debug_headers');How to log cache hits and misses in Redis TimeSeries
// Log cache metrics to Redis TimeSeries
function log_cache_metric($metric, $value) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
// Add to time series
$redis->rawCommand('TS.ADD',
"cache:{$metric}",
'*',
$value,
'LABELS', 'site', get_site_url(), 'type', 'wordpress'
);
}
// Log on every request
add_action('wp', function() {
log_cache_metric('requests', 1);
if (did_cache_hit()) {
log_cache_metric('hits', 1);
} else {
log_cache_metric('misses', 1);
}
});Cache monitoring dashboard in WordPress admin
Create a WordPress admin dashboard for cache monitoring:
// Add cache health page
function add_cache_health_page() {
add_management_page(
'Cache Health',
'Cache Health',
'manage_options',
'cache-health',
'render_cache_health_page'
);
}
add_action('admin_menu', 'add_cache_health_page');
function render_cache_health_page() {
$stats = wp_cache_get_stats();
$hit_rate = $stats['hits'] / ($stats['hits'] + $stats['misses']) * 100;
?>
<div class="wrap">
<h1>Cache Health Dashboard</h1>
<div class="cache-stats">
<div class="stat-box">
<h3>Hit Rate</h3>
<div class="stat-value <?php echo $hit_rate > 90 ? 'good' : 'warning'; ?>">
<?php echo number_format($hit_rate, 1); ?>%
</div>
</div>
<div class="stat-box">
<h3>Cached Objects</h3>
<div class="stat-value"><?php echo number_format($stats['objects']); ?></div>
</div>
<div class="stat-box">
<h3>Memory Used</h3>
<div class="stat-value"><?php echo size_format($stats['memory']); ?></div>
</div>
</div>
</div>
<?php
}How WordPress Site Health detects page cache and object cache
Since WordPress 6.1, Tools, Site Health runs two tests that check caching from the inside, and their rules are public in core. Reading them explains why a site with a working CDN can still see a warning.
The page cache test makes three requests to the homepage, records the response headers and takes the median response time. A response counts as cached when it carries one of the headers listed in get_page_cache_headers(): cache-control with a non-zero max-age, an expires date in the future, a positive age, last-modified, etag or via, plus vendor headers such as cf-cache-status, x-cache, x-cache-status, x-litespeed-cache, x-srcache-fetch-status with a HIT value, or x-varnish with two request IDs. A page cache plugin is detected only when three things are true in core’s code: wp-content/advanced-cache.php exists, the WP_CACHE constant is true, and the enable_loading_advanced_cache_dropin filter has not been switched off. The same three conditions decide whether wp-settings.php loads advanced-cache.php at all, very early, before plugins.
The response time threshold is 600 milliseconds by default. Core’s get_good_response_time_threshold() documents that the value is based on web.dev guidance for time to first byte, and the site_status_good_response_time_threshold filter changes it. A site behind Cloudflare that answers in 900 ms from the origin, because the test bypasses the edge or the HTML is not edge-cached, will be flagged even if visitors get fast responses.
The persistent object cache test recommends Redis or Memcached when any of these is true:
- the site is a multisite network (always suggested),
- more than 500 autoloaded options, or more than 100,000 bytes of serialized autoloaded options,
- 1,000 or more rows in the comments, options, posts, terms or users table (core reads InnoDB row estimates from
information_schema, not exact counts).
The thresholds are filterable through site_status_persistent_object_cache_thresholds, and site_status_should_suggest_persistent_object_cache short-circuits the whole check. Hosts that provide object caching at another layer use that filter, so a missing warning does not prove a drop-in is installed: check for wp-content/object-cache.php, or call wp_using_ext_object_cache().
Common WordPress caching mistakes and fixes
How to avoid over-aggressive caching
Problem: Caching everything leads to stale content and user frustration.
Solution: Implement smart cache exclusions:
function should_cache_page() {
// Don't cache for logged-in users
if (is_user_logged_in()) {
return false;
}
// Don't cache dynamic forms
if (is_page('contact') || is_page('checkout')) {
return false;
}
// Don't cache search results (too many variations)
if (is_search()) {
return false;
}
return true;
}How to prevent a cache stampede
Problem: When a popular cache item expires, hundreds of concurrent requests find a cache miss simultaneously. Each process initiates an identical heavy SQL query or remote API call against the origin database, causing extreme CPU spikes, lock contention, and server crashes (dogpiling or thundering herd).
Solution: Implement probabilistic early expiration using the XFetch algorithm or distributed Redis mutex locks:
Probabilistic early expiration with the XFetch algorithm
The optimal stampede prevention approach avoids hard locks entirely. Proposed by Vattani, Chierichetti, and Lowenstein, the XFetch algorithm computes an early refresh probability that increases as the item nears expiration and as the computation time grows. A worker refreshes the cache item ahead of time while continuing to serve valid cached data to all incoming requests:
/**
* Probabilistic early cache recomputation (XFetch algorithm).
*
* @param string $key Cache identifier
* @param callable $compute Callback generating fresh data
* @param int $ttl Cache TTL in seconds
* @param float $beta Aggressiveness multiplier (> 0, default 1.0)
* @return mixed Cached or computed payload
*/
function xfetch_cache_get($key, callable $compute, $ttl = 3600, $beta = 1.0) {
$cached = wp_cache_get($key, 'xfetch_group');
// Expected format: ['data' => ..., 'delta' => compute_time_seconds, 'expiry' => timestamp]
if (is_array($cached) && isset($cached['data'], $cached['delta'], $cached['expiry'])) {
$remaining = $cached['expiry'] - time();
// Compute probabilistic trigger: -beta * delta * ln(random_float(0, 1))
$random = mt_rand(1, 1000000) / 1000000;
$early_trigger = -($beta * $cached['delta'] * log($random));
// If remaining lifetime exceeds early trigger threshold, return cached value immediately
if ($remaining > $early_trigger) {
return $cached['data'];
}
}
// Refresh cache item
$start = microtime(true);
$fresh_data = $compute();
$delta = microtime(true) - $start;
$payload = [
'data' => $fresh_data,
'delta' => max($delta, 0.001),
'expiry' => time() + $ttl,
];
wp_cache_set($key, $payload, 'xfetch_group', $ttl * 2);
return $fresh_data;
}Distributed mutex lock with Redis
When background pre-computation is not suitable, a distributed mutex lock guarantees that exactly one PHP-FPM process recalculates the expensive query:
function get_cached_with_distributed_lock($key, callable $compute, $ttl = 3600) {
$data = wp_cache_get($key, 'distributed_cache');
if (false !== $data) {
return $data;
}
$lock_key = 'lock:' . $key;
// Atomic SET lock:key token NX EX 10
$lock_acquired = wp_cache_add($lock_key, 1, 'distributed_locks', 10);
if ($lock_acquired) {
try {
$fresh_data = $compute();
wp_cache_set($key, $fresh_data, 'distributed_cache', $ttl);
return $fresh_data;
} finally {
wp_cache_delete($lock_key, 'distributed_locks');
}
}
// If another process holds the lock, wait briefly or serve fallback data
usleep(80000); // 80ms sleep
$data = wp_cache_get($key, 'distributed_cache');
return (false !== $data) ? $data : $compute();
}How to vary the cache for mobile devices
Problem: Desktop and mobile users get the same cached version, breaking responsive design.
Solution: Vary cache by device type:
// Add device type to cache key
function get_device_cache_key($base_key) {
$device = wp_is_mobile() ? 'mobile' : 'desktop';
return "{$base_key}_{$device}";
}
// Or use Vary header for CDN
function add_device_vary_header($headers) {
$headers['Vary'] = 'User-Agent';
return $headers;
}Conclusion
Caching is no longer about saving space - it’s about saving time. In 2026, an intelligent caching strategy is the difference between an enterprise-grade WordPress site and a lagging competitor.
By mastering Edge Caching, Cache Tags, Fragment Caching, and SWR patterns, you transform your WordPress site into a distributed high-performance engine capable of handling millions of requests with sub-50ms response times.
Key Takeaways:
- Implement triple-layer edge caching for global performance
- Use cache tagging for surgical invalidation
- Deploy Redis 8+ for enterprise object caching
- Combine static and dynamic content with fragment caching
- Embrace SWR for instant user experiences
- Monitor cache health continuously
- Test thoroughly - cached bugs are harder to find
Is your site’s cache working for you or against you? Contact WPPoland to audit and upgrade your caching architecture today.
Related resources
- WordPress Performance: The Complete 2026 Guide - Comprehensive performance optimization strategies
- WooCommerce Caching Best Practices - E-commerce specific caching strategies






