DEV Community

nocache.dev
nocache.dev

Posted on

WordPress page cache vs CDN cache: who is answering your request?

"I cleared the cache and nothing changed" is the most common WordPress support sentence I know. The honest reply is: which one?

A typical WordPress site can have three page caches stacked on top of each other, each with its own rules for what to store, when to skip, and how to purge. A request hits them from the outside in, so the first one that has a copy wins and the others never hear about it.

I ran live checks on two WordPress sites today. First, the layers.

Three caches, outside in

Edge cache (CDN). Cloudflare, Fastly, CloudFront and the like. Closest to the visitor, so it answers first. Cloudflare in particular doesn't cache HTML by default: it caches by file extension, and HTML pages come back with cf-cache-status: DYNAMIC until you add a Cache Rule (or a product like APO) that makes them eligible.

Server cache. A reverse proxy in front of PHP: Varnish or nginx at a managed host, or LiteSpeed's built-in LSCache. It stores the full response in memory or on disk and never wakes PHP on a hit.

Plugin page cache. WP Rocket, W3 Total Cache, WP Super Cache. These write the rendered page to a static HTML file under wp-content/cache. Depending on the setup, the file is served by a rewrite rule (no PHP at all) or by advanced-cache.php early in the WordPress boot.

You don't need all three, but you need to know which ones you have: each has its own purge and its own bypass rules.

Telltale signatures

  • Cloudflare: cf-cache-status (HIT, MISS, DYNAMIC, BYPASS, EXPIRED...).
  • LiteSpeed: x-litespeed-cache: hit, miss, or hit,private for its per-user private cache.
  • Varnish-style host caches: usually x-cache plus something host-specific. WP Engine adds x-cacheable and x-cache-group, and x-pass-why when it skips the cache.
  • WP Rocket: no header. It appends an HTML comment at the very end of the page: This website is like a Rocket, isn't it? Performance optimized by WP Rocket. When the page was served from WP Rocket's cache file, the comment ends with - Debug: cached@<unix timestamp>.
  • W3 Total Cache: also an HTML comment, starting Performance optimized by W3 Total Cache, with lines like Page Caching using Disk: Enhanced and Served from: ... @ <date>.

Two caveats. HTML minification can strip those comments, so their absence proves nothing. And an age header only tells you how long the copy has sat in the cache that answered, not in the ones behind it.

Live check 1: wordpress.org/news

The nocache.dev check, trimmed to caching headers:

server: nginx
vary: Accept-Encoding, accept, content-type
x-nc: MISS dca 2
Enter fullscreen mode Exit fullscreen mode

Two curl -sI requests right after:

x-nc: EXPIRED dca 2
x-nc: HIT dca 1
Enter fullscreen mode Exit fullscreen mode

There's no CDN signature here, no Cache-Control, no ETag and no Last-Modified. The only cache status comes from x-nc, the nginx cache that sits in front of WordPress.org. The three requests got MISS, EXPIRED and HIT, and the trailing dca 1 / dca 2 changed between requests, so I was most likely talking to different cache nodes, each with its own copy.

A server cache is doing all the work. It's fast, but the browser was never told how long to keep the page, so it guesses, and with no validators it can't revalidate cheaply. The check graded it D, mostly for those two gaps: missing Cache-Control and no validators.

Live check 2: a WooCommerce store with three layers

For a store I used nalgene.com, a WooCommerce site. Trimmed result:

cache-control: max-age=600, must-revalidate
cf-cache-status: HIT
age: 179
x-cache: HIT: 1
x-cache-group: normal
x-cacheable: SHORT
vary: Accept-Encoding, Accept-Encoding,Cookie
last-modified: Tue, 27 Oct 2026 13:18:03 GMT
Enter fullscreen mode Exit fullscreen mode

And at the bottom of the HTML:

<!-- This website is like a Rocket, isn't it? Performance optimized by WP Rocket. Learn more: https://wp-rocket.me -->
Enter fullscreen mode Exit fullscreen mode

All three layers show up:

  1. Cloudflare answered (cf-cache-status: HIT, age: 179), so HTML caching has been turned on there.
  2. A WP Engine-style Varnish layer behind it (x-cacheable, x-cache-group, and WP Engine's default 600-second page TTL in max-age=600).
  3. WP Rocket in WordPress, but with no cached@ timestamp. WP Rocket's docs say that happens when its page cache is off, for example on hosts that run their own page cache. Here it is doing optimisation only, and the host does the caching.

One detail is easy to misread. I repeated the request twice with curl -sI and got x-cache: HIT: 1 and age: 193 both times. That x-cache value isn't live. It's the header Varnish attached when Cloudflare first fetched the page, and Cloudflare stores and replays it with every hit. On an edge HIT, every header from the inner layers is a snapshot from the moment the edge filled its cache. To see what the inner layers are doing right now, you have to get past the edge.

Logged-in users: what WordPress actually sends

For logged-in requests, WordPress core adds wp_get_nocache_headers() to the front-end response (in WP::send_headers()). Since WordPress 6.3, that means:

Cache-Control: no-cache, must-revalidate, max-age=0, no-store, private
Expires: Wed, 11 Jan 1984 05:00:00 GMT
Enter fullscreen mode Exit fullscreen mode

Before 6.3 it was no-cache, must-revalidate, max-age=0 without no-store, private, and since 6.8 every call to nocache_headers() sends the longer version. Core also sends these headers on 404s for anonymous visitors. WooCommerce uses the same mechanism on the cart, checkout and account pages, and also defines DONOTCACHEPAGE so page cache plugins skip them.

The 1984 Expires date is the giveaway. Here is nalgene.com's cart page:

cache-control: no-cache, must-revalidate, max-age=0, private
expires: Wed, 11 Jan 1984 05:00:00 GMT
x-pass-why: custom-path
cf-cache-status: BYPASS
Enter fullscreen mode Exit fullscreen mode

The host skipped the cache by path (x-pass-why), and Cloudflare saw private and bypassed it too. That's what you want.

Cookies decide who gets the cached copy

Headers on the response only matter once a cache has decided to ask the origin. The decision to skip the cache for a given visitor is made earlier, from request cookies: wordpress_logged_in_* for signed-in users, and for WooCommerce woocommerce_items_in_cart, woocommerce_cart_hash and wp_woocommerce_session_*. WooCommerce's docs list those cookies as the ones to exclude.

Every layer needs its own copy of those rules. I sent nalgene.com's homepage two requests with dummy cookies:

With wordpress_logged_in_abc=1:

cache-control: max-age=0, must-revalidate, private
x-cache: MISS
x-cacheable: NO:Passed
cf-cache-status: DYNAMIC
Enter fullscreen mode Exit fullscreen mode

Both layers stepped aside. With woocommerce_items_in_cart=1; wp_woocommerce_session_abc=1:

cache-control: max-age=600, must-revalidate
x-cache: HIT: 1
cf-cache-status: HIT
age: 197
Enter fullscreen mode Exit fullscreen mode

Cloudflare served its cached copy. WP Engine documents those WooCommerce cookies as cache exclusions, but the request never reached WP Engine. On a homepage that may well be fine, if the mini-cart is filled in by JavaScript after the page loads. But this is how carts end up showing the wrong count: the host's cookie rules are correct, and the CDN in front never got the same rules.

The rule of thumb: any cookie that bypasses an inner cache has to bypass every cache in front of it too.

Purge from the inside out

After publishing or editing, clear caches in the same order a request travels back out:

  1. Plugin page cache.
  2. Server cache (host dashboard, or LiteSpeed's purge).
  3. CDN.

If you purge the CDN first, the next visitor makes the CDN fetch from a server cache that still holds the old page, and the stale copy goes straight back to the edge.

A quick checklist

  1. curl -sI a page twice while logged out. Write down every cache status header you see.
  2. View the source and look for plugin comments at the end.
  3. Repeat with a wordpress_logged_in_x=1 cookie and with a WooCommerce cart cookie. Every layer should step aside.
  4. Check /cart/ and /checkout/ for private and the 1984 Expires.
  5. Make sure HTML has an explicit Cache-Control, not just a server cache and heuristics.

I built a small WordPress plugin for steps 1 and 5. It grades your home page, latest post, a page, the WooCommerce shop and the theme stylesheet, and if no cache plugin is active it can apply sensible headers (with a revert button). If WP Rocket, W3 Total Cache, LiteSpeed Cache or WP Super Cache is active, it only reports. It's in the WordPress.org review queue, so for now it's a zip on nocache.dev/wordpress. To check from a terminal: npx nocache-dev check https://your-site.com.

Top comments (0)