WordPress Caching Explained: Page, Object, Browser and CDN
WordPress caching explained properly: page cache, object cache, browser headers and CDN edge rules, with real Nginx, Redis and purge config from production
We inherited a WooCommerce site last year that had three caching plugins active at once. W3 Total Cache for page caching, a Redis plugin for the object cache, and a “speed optimiser” plugin that had quietly enabled its own disk cache on top. Time to first byte was 1.4 seconds on a cache hit. On a miss it was worse than the uncached baseline, because two of the three layers were serializing the same data into the same Redis instance with different key prefixes.
That’s what happens when you treat WordPress caching as a plugin choice instead of an architecture. There are four distinct caches in a normal WordPress request, five if you count OPcache, and they solve different problems at different points in the stack. Get the order right and a shared-hosting site can serve 40ms HTML. Get it wrong and you ship stale prices to customers.
This is the map we use on every performance engagement, plus the config that actually goes into production.
- The four layers sit in a strict order: browser cache, CDN or edge cache, full page cache, object cache. Each one only helps requests that the layer above it missed, so measure hit rates per layer, not just overall TTFB.
- A persistent object cache (Redis) does nothing for anonymous cached page views and everything for logged-in users, WooCommerce checkout, and wp-admin. It’s the layer most agencies skip and the one that fixes the slowest pages.
- Full page caching breaks anything that depends on per-request state: nonces, cart counts, personalised greetings, A/B tests. Solve it with cookie-based bypass rules and client-side hydration, not by disabling the cache.
- Set
Cache-Control: max-age=31536000, immutableon versioned static assets ands-maxagewith an explicit purge on HTML. Never put a long browser max-age on HTML, because you cannot purge a browser. - Cache invalidation is the whole job. Design your purge strategy (URL list, cache tags, or full flush) before you pick a plugin.
Follow one request through all four layers
A visitor hits /blog/some-post/. Here’s what can answer, in order, cheapest first:
- Browser cache. If the HTML is still fresh in the local disk cache, nothing leaves the machine. Zero network. This almost never applies to HTML and almost always applies to CSS, JS, fonts and images.
- CDN edge cache. A PoP 30km from the user returns the stored HTML. Typically 10 to 40ms, no origin contact at all.
- Full page cache at origin. Nginx
fastcgi_cache, Varnish, LiteSpeed LSCache, or a PHP-level disk cache. PHP may not even start. - Object cache. PHP runs, WordPress boots, but individual queries, term lookups and expensive computations come back from Redis instead of MySQL.
- MySQL and PHP. The slow path. Everything you build is an attempt to avoid this.
The important consequence: layers 1 to 3 are mutually exclusive per request. If your edge cache hit rate is 95%, tuning your object cache changes nothing for 95% of anonymous traffic. It changes everything for the 5% that miss, plus every logged-in session, which is where the real complaints come from. We wrote up the ordering method in more detail in our diagnostic guide to slow WordPress sites.

Page caching: the biggest win and the biggest liability
Full page caching stores the rendered HTML and serves it without touching PHP. On a stock WordPress install with a mid-weight theme, that’s the difference between roughly 400 to 900ms of TTFB and something under 50ms. It’s the single highest-leverage thing you can do.
Our default is Nginx fastcgi_cache rather than a PHP plugin. A PHP plugin still has to boot WordPress far enough to decide it has a cache hit. That’s 8 to 20ms of PHP even on the happy path, plus whatever your host’s PHP-FPM queue is doing under load. Nginx answers before PHP exists.
# /etc/nginx/conf.d/wp-cache.conf
fastcgicachepath /var/cache/nginx/wp levels=1:2 keyszone=WPCACHE:100m inactive=12h maxsize=2g;
map $httpcookie $skipcache {
default 0;
# Logged in, commenting, password-protected, or has a cart
"~*wordpressloggedin|wp-postpass|commentauthor|woocommerceitemsincart|woocommercecart_hash" 1;
}
map $requesturi $skipuri {
default 0;
"~*/wp-admin/|/wp-json/|/cart/|/checkout/|/my-account/|sitemap" 1;
}
location ~ \.php$ {
fastcgi_cache WPCACHE;
fastcgicachekey "$scheme$requestmethod$host$requesturi";
fastcgicachevalid 200 301 302 12h;
fastcgicachebypass $skipcache $skipuri $arg_nocache;
fastcginocache $skipcache $skipuri;
# Serve the stale copy while one request regenerates: kills cache stampedes
fastcgicacheusestale updating error timeout http500 http_503;
fastcgicachelock on;
fastcgicachebackground_update on;
addheader X-Cache $upstreamcache_status always;
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
fastcgicachelock and use_stale updating are the two lines people leave out. They’re also the reason sites fall over the moment a popular page expires. Without them, 200 concurrent requests for an expired key all become PHP requests simultaneously. With them, one request regenerates and the other 199 get the slightly stale copy.
What page caching breaks is predictable. Nonces first: WordPress nonces are tied to a user session and a 12 hour tick, so a cached comment form or a cached AJAX nonce will start returning “are you sure you want to do this” for anonymous visitors after the tick rolls over. Cart counts, view counters, “last updated 2 minutes ago” widgets all freeze. The fix is to render those fragments client-side rather than to punch holes in your caching. If your contact forms are posting through admin-ajax.php with a nonce, moving them to a stateless endpoint like WebForms removes the nonce-versus-cache conflict entirely, since there’s no WordPress session involved in the submission.
Object cache in WordPress: what it actually does
Here’s the thing most tutorials get wrong. WordPress always has an object cache. WPObjectCache is instantiated on every request and it caches within that request only. Ask for the same post object twice and the second call is free. End the request and it’s all discarded.
Making it persistent means dropping in wp-content/object-cache.php, which redirects those same wpcacheget() calls to Redis or Memcached so they survive across requests. That’s the entire mechanism. An object cache wordpress setup is not a separate system, it’s a backend swap for an API that’s already running.
Where it pays off:
- wp-admin. Never page-cached, hits the database hard, and is where your client forms their opinion of the site. We routinely see admin page loads go from 1.2s to 400ms with Redis alone.
- Logged-in and transactional pages. Checkout, account pages, membership dashboards. These bypass the page cache by design.
- Transients. With a persistent object cache installed, transients stop writing to
wp_optionsand go to Redis instead. On a site with aggressive API caching, that removes thousands of autoloaded option rows and a lot of write contention. - Cache misses. Every page-cache regeneration is cheaper.
// wp-config.php, above the "That's all, stop editing" line
define( 'WPREDISHOST', '127.0.0.1' );
define( 'WPREDISPORT', 6379 );
define( 'WPREDISTIMEOUT', 1 ); // seconds; fail fast rather than hang the page
define( 'WPREDISREAD_TIMEOUT', 1 );
define( 'WPREDISDATABASE', 0 );
// Mandatory on shared Redis: without this, two sites will read each other's keys
define( 'WPREDISPREFIX', 'acme_prod:' );
define( 'WPCACHEKEYSALT', 'acmeprod:' );
Set Redis itself to maxmemory 256mb and maxmemory-policy allkeys-lru. If you leave the default noeviction, Redis starts throwing write errors when it fills, and depending on the drop-in you get either silent cache failure or fatal errors. We’ve debugged that at 2am more than once.
Check it’s actually working, not just installed:
redis-cli info stats | grep keyspace
keyspace_hits:1842911
keyspace_misses:38402 -> ~98% hit ratio, healthy
Anything under ~90% usually means an alloptions key is being invalidated on every write
wp cache flush # confirms the drop-in responds
wp transient delete --expired --network
One live trap: the alloptions key. WordPress caches all autoloaded options as a single blob, so one option update invalidates the lot. If a plugin writes an option on every page view, your object cache hit ratio collapses and you’re serializing a 400KB payload in and out of Redis constantly. Audit it:
-- WP 6.6+ uses 'on'/'auto-on' alongside the legacy 'yes'
SELECT optionname, LENGTH(optionvalue) AS bytes
FROM wp_options
WHERE autoload IN ('yes','on','auto','auto-on')
ORDER BY bytes DESC
LIMIT 20;
Anything over 100KB in that list deserves an explanation. Total autoloaded size should sit well under 800KB.
Browser caching: two headers, done properly
Browser caching is the cheapest layer and the one people configure by copying a random snippet into .htaccess. It comes down to two decisions.
Static assets with a version in the URL get a year and immutable. WordPress appends ?ver=6.8.1 to core and theme assets, which is part of the cache key in every real browser, so this is safe:
location ~* \.(css|js|woff2|jpg|png|webp|avif|svg)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
One caveat: some CDN configurations and corporate proxies strip query strings before caching. If you’re seeing stale CSS after a deploy at the edge but not locally, that’s your culprit. Fingerprinting the filename itself is more reliable than ?ver=, which is why build-step themes beat drag-and-drop ones on this particular problem.
HTML gets a short browser TTL and a long shared TTL. This is the pattern that matters:
Cache-Control: public, max-age=0, s-maxage=604800, stale-while-revalidate=60
Browsers revalidate every time, which is one cheap conditional request. The CDN holds it for a week and you purge on publish. Never set max-age=3600 on HTML: you’ve just guaranteed that some percentage of your users see a week-old price for an hour after you fix it, with no way to reach them.
WordPress CDN: edge caching beats asset offload
There are two things people mean by a wordpress cdn, and only one of them is worth configuring in 2026.
The old model was asset offload: rewrite /wp-content/uploads/ URLs to a CDN hostname so images and CSS come from a PoP. Useful in 2014. Today it saves you a few tens of milliseconds on subresources while your HTML still takes a full round trip to a server in Frankfurt.
The model that works is full HTML at the edge. Cache the page itself in the CDN, bypass on the session cookies, purge on publish. Same rules as the origin page cache, one hop closer to the user. On a Cloudflare Cache Rule that means matching your bypass cookies and setting Edge TTL from origin headers, so your s-maxage is the single source of truth rather than a value duplicated in a dashboard nobody remembers editing.
Purge granularity is where plans differ. Purge by URL is universally available and fine for a blog: on publish, purge the post URL, the homepage, the category archives and the feed. Purge by cache tag is enterprise-tier on Cloudflare but standard on Fastly and KeyCDN, and it’s the correct tool once a post appears in a dozen archive pages:
addaction( 'templateredirect', function () {
if ( isuserloggedin() || is404() ) {
return;
}
$tags = [ 'site' ];
if ( is_singular() ) {
$tags[] = 'post-' . getqueriedobject_id();
foreach ( (array) gettheterms( getqueriedobject_id(), 'category' ) as $term ) {
$tags[] = 'term-' . $term->term_id; // archive pages share this tag
}
}
header( 'Cache-Tag: ' . implode( ',', array_unique( $tags ) ) );
} );
Then on save_post, purge post-123 and its term tags in one API call. Every page that embedded that post drops out of the edge cache, including ones you forgot existed.
Image delivery is the one asset case still worth CDN-specific handling, mostly for on-the-fly AVIF and WebP conversion and responsive resizing. We cover the sizing side in our image optimisation guide.
Invalidation is the actual engineering problem
Caching is easy. Knowing when a cached thing is wrong is hard, and it’s where most WordPress setups quietly rot.
Three strategies, in increasing order of correctness:
- Flush everything on any change. What most plugins do by default. Safe, correct, and disastrous on a busy site because you’ve just thrown away your entire cache to fix one post. On a site doing 50 edits a day, your effective hit rate never recovers.
- Purge a URL list. Post URL, home, feed, sitemap, category and tag archives, author archive. Covers 90% of cases. Misses the “related posts” block on 40 other pages.
- Tag-based purge. Correct, surgical, requires a CDN that supports it and about an hour of setup.
Whatever you pick, add a short TTL as a backstop. We use 12 hours at the origin and 7 days at the edge with explicit purging. If a purge hook silently fails, the worst case is half a day of staleness rather than permanent.
Two things we always exclude from every cache layer: /wp-json/ endpoints that return user-specific data, and anything under a query string you don’t control. Caching ?utm_source= variants as separate keys will shred your hit ratio. Strip marketing parameters from the cache key at the edge instead.
A stack that holds up, by site type
Brochure or content site. Nginx fastcgi_cache or LiteSpeed LSCache, Cloudflare Cache Rules on HTML with cookie bypass, immutable assets, OPcache tuned. Object cache optional but cheap. Expect 20 to 60ms TTFB at the edge.
WooCommerce. All of the above, plus Redis as a non-negotiable, plus bypass rules on cart, checkout, account and ?add-to-cart=. Kill the cart fragments AJAX call on pages that don’t show a mini-cart: on a large catalogue that one uncached request per page view costs more than everything else you’ve optimised.
Membership, LMS, community. Page caching helps almost nobody here because nearly all traffic is authenticated. Put your effort into the object cache, query tuning and a fast origin. Trying to page-cache a logged-in experience with cookie-keyed variants is how you end up serving one user’s dashboard to another.
Don’t forget OPcache, which sits outside all of this and compiles your PHP once instead of on every request:
opcache.memory_consumption=256
opcache.maxacceleratedfiles=50000 ; WP + plugins easily exceeds the 10000 default
opcache.validate_timestamps=0 ; production only: requires a reload on deploy
opcache.jit=disable ; JIT rarely helps WordPress workloads
Set validate_timestamps=0 and forget to reload PHP-FPM after a deploy, and your site will keep running the old code. That’s a real failure mode. Wire systemctl reload php8.3-fpm into your deploy script before you touch this setting. If you want the wider picture on how these layers feed into field metrics, the 2026 performance playbook covers the measurement side.
Frequently Asked Questions
Do I still need an object cache if I have a good page cache?
Yes, if your site has logged-in users, a shop, or a client who works in wp-admin daily. Page caching does nothing for authenticated requests, which are exactly the requests that bypass it. On a pure anonymous-traffic brochure site, an object cache is a marginal gain and you could skip it.
Redis or Memcached for the WordPress object cache?
Redis, in almost every case. It supports data structures, persistence, eviction policies you can tune, and the tooling around it (the Redis Object Cache plugin, Object Cache Pro) is far better maintained than the Memcached equivalents. Memcached is only preferable if your host already runs it and won’t give you Redis.
Why do my forms and comments break after I enable caching?
Almost always nonces. A nonce is generated per user and per 12 hour tick, so once it’s baked into a cached HTML page it goes stale and WordPress rejects the submission. Fix it by fetching the nonce over an uncached AJAX call at submit time, or by posting to a stateless endpoint that doesn’t use WordPress sessions at all.
Is a CDN enough on its own, without origin caching?
No. The CDN still has to fetch from your origin on every cache miss, every purge, and for every one of the hundreds of PoPs that hasn’t seen the page yet. A slow origin under an edge cache produces wildly inconsistent TTFB depending on which city the visitor is in. Fix the origin first, then put the CDN in front of it.
How do I tell which layer served a given request?
Check the response headers with curl -sI https://example.com/. Look for X-Cache: HIT from Nginx, cf-cache-status from Cloudflare, and x-litespeed-cache if you’re on LiteSpeed. Add ?nocache=1 or a random query parameter to force an origin miss and compare the timings, which tells you what your uncached path really costs.
If you only do one thing this week, open your site in an incognito window, run curl -sI against the homepage and a random post, and see which cache headers come back. Most of the sites we audit have a page cache that’s silently bypassed on every request because of a stray cookie, and nobody noticed because the staging server was fast enough to hide it. Confirm the hit before you tune anything else.


