Caching layers
Page Caching and What It Breaks
A full-page cache serves one response to everyone. Here is what that breaks, which requests to exclude, and how to prove a cache is serving what you think.
A store turns on full-page caching from its host's dashboard. The homepage loads in 40 ms and the owner is pleased. Two days later support has three tickets about a mini-cart that shows items from someone else's session, and the owner is told that the cart page is "excluded by default", which is true and does not address the problem, because the fragment that broke was not on the cart page.
A full-page cache stores the finished HTML for a URL and hands the same bytes to every visitor who asks for that URL until the entry expires. It achieves that by making a decision about the request without knowing anything about the person making it. Everything that breaks under a page cache breaks because the response depends on something the cache key does not contain: a session, a user, a nonce, a cart, a greeting with somebody's first name in it.
The measurements below come from the lab install from the previous lesson: WordPress 7.0.7 on PHP 8.5.6, behind a deliberately naive full-page cache that keys on the URL alone and skips a short list of cookies. The cache is a drop-in written for the exercise, and you will reproduce these results on any cache that gets the same decision wrong.
A page cache stores one response per URL, not one per visitor
To see what that means, put a personalised value on a page and fetch it twice as a visitor with no cookies. Here is the fragment, rendered by a hook that stands in for a theme header:
$user = wp_get_current_user();
$name = $user->exists() ? $user->display_name : 'guest';
$nonce = wp_create_nonce( 'm3_ajax_action' );
echo "<p>Hello, " . esc_html( $name ) . "</p>";
echo "<p>nonce: " . esc_html( $nonce ) . "</p>";
Two anonymous requests, one after the other, with the cache indicator in the response headers:
request 1 X-M3-Page-Cache: MISS-STORED Hello, guest nonce: 5d26e1adf4
request 2 X-M3-Page-Cache: HIT Hello, guest nonce: 5d26e1adf4
Nothing looks wrong yet, because both visitors are guests. The second response was read from a file on disk without booting WordPress, and it contains a nonce that was minted during the first request. Now ask the same URL as a logged-in user:
logged in X-M3-Page-Cache: BYPASS Hello, lab nonce: 84c902d6fc
The logged-in request is excluded by the cache because of its
wordpress_logged_in_ cookie, so it renders normally and gets its own nonce. The
guest response is still shared, and every guest who reaches that page receives
5d26e1adf4. Do not read that as a security hole on its own: a guest nonce is
generated for user ID 0, it has no session token to bind to, and it is the same value for every
anonymous visitor on the site, so it authenticates nothing that was not already public. The
finding is about correctness and blast radius. A nonce printed into a cached page is a value you
no longer control, it will be wrong for anyone whose session changes mid-life, and on a page that
combines a nonce with a per-visitor value you have started handing one visitor's context to
another.
A nonce in a cached page is a value you no longer control
The greeting is the clearer failure. Change Hello, guest to a first name read
from a cookie, or from a server-side session, and the cache has no way to notice. Whatever the
first request rendered is what the next thousand visitors receive.
Cart, checkout and account pages are excluded because they are per-session
The standard exclusion list is not a style preference. Each item on it marks a request whose response depends on state the cache key cannot see. In the lab cache the list is short and explicit, and a production cache's list is longer in the same shape:
wordpress_logged_in_* // the visitor is authenticated
wordpress_sec_* // secure-auth variant of the same cookie
wp-postpass_* // password-protected post unlocked
comment_author_* // commenter identity, used to prefill forms
woocommerce_items_in_cart // the cart is not empty
woocommerce_cart_hash // the cart contents changed
wp_woocommerce_session_* // there is an active WooCommerce session
Two request properties belong on the list as well. A non-GET request is a write and must never
be served from a stored response. A request with a query string is a different response in
practice, especially on a store, where ?add-to-cart=, ?orderby= and
filter parameters all produce pages a shared entry should not answer.
The trap is the mini-cart, and it is the reason the cart page exclusion is not enough. The header fragment that shows two items and a subtotal is rendered into every page, including the homepage and the shop archive, which are exactly the pages a page cache is for. A cache plugin that excludes the cart URL has excluded the page where the problem is obvious and left the fragment in place everywhere else. WooCommerce's own approach is to render the fragment empty in the cached HTML and fill it on the client through an authenticated AJAX request, which is a fragment cache wearing a different name.
A fragment cache decides what to cache for whom
The difference between the two layers is the unit and the key. A full-page cache stores one response for a URL. A fragment cache stores one piece of output under a key that includes whatever makes the piece different for different visitors, so the same page can be assembled from a cached, shared header and a cached, per-user cart block.
| Full-page cache | Fragment cache | |
|---|---|---|
| Stored key | URL (plus a host and maybe a device class) | URL or object plus user, session or input arguments |
| Bytes served | The complete response body | One block of markup or one API payload |
| Correct for | Requests that are identical for everyone | Anything whose key names every input that changes it |
| Failure mode | One visitor's context served to everyone | A key missing an input, so two contexts share a value |
| Where it runs | Before PHP, or on the first PHP hook | Inside the template, after PHP has a user and a session |
Fragment caching is not free of the same class of bug; it moves the question to the key. A cart fragment keyed by URL only is a full-page cache with more steps. The rule is the one from the invalidation article: whatever the fragment depends on has to be in its key, and the day somebody adds a dependency, the key has to grow with it.
Caching the wrong page is a correctness bug, not a slow page
Every other optimisation in this course degrades. A missing index costs milliseconds, an unoptimised query costs more, and an unbounded loop costs the most. A wrongly cached page does not degrade, it lies: a visitor sees a cart that is not theirs, a price from yesterday, or a "welcome back" with another person's name on it. There is no acceptable version of that trade, and the effort you save by not excluding a cookie is paid by support.
This is also why the decision belongs to whoever understands the session model rather than to whoever configured the plugin. The failure is usually a page that is correct for 99.9% of requests and catastrophic for the rest, which is a shape that passes every test written before the cache was installed.
Verify with headers, a cookie probe and a logged-in comparison
You can check a page cache from the command line in about two minutes, and the workflow is worth memorising because it also works against a CDN and against a hosting panel you do not control.
First, find the indicator. A cache that will not tell you whether it served the response is a
cache you cannot debug, so establish that the header is present before you do anything else.
Common names are X-Cache, X-Cache-Status, X-LiteSpeed-Cache,
CF-Cache-Status and Age; the lab cache uses one of its own:
# Two requests, anonymous. The second should be a hit.
curl -sD - -o /dev/null https://example.test/ | grep -iE 'x-cache|age:|cache-control'
curl -sD - -o /dev/null https://example.test/ | grep -iE 'x-cache|age:|cache-control'
Second, probe one exclusion at a time. A cache decides whether to store a response by reading the request, so you can test its decision by sending the request with and without the marker:
# Does a logged-in cookie change the answer?
curl -sD - -o /dev/null -H 'Cookie: wordpress_logged_in_1=anything' https://example.test/ | grep -i x-cache
# Does a query string?
curl -sD - -o /dev/null 'https://example.test/?add-to-cart=42' | grep -i x-cache
# Does a WooCommerce session cookie?
curl -sD - -o /dev/null -H 'Cookie: wp_woocommerce_session_abc=1' https://example.test/ | grep -i x-cache
If the answer to any of those is a hit, the response was stored for a request that should not have received a shared copy, and you have a finding. The values you send do not have to be valid; you are testing whether the cache consults the cookie before it decides, and a cache that checks correctly will bypass a response to a cookie it cannot validate, because bypassing costs a PHP request and serving the wrong bytes costs a customer.
Third, compare bodies rather than headers. Two anonymous requests should differ in no personalised way, and a logged-in request should differ in exactly the ways it must. Diffing the two reveals both problems at once: a value that changes between two anonymous requests is probably being served from a stale entry, and a value that fails to change between an anonymous and a logged-in request is being served from a shared one.
curl -s https://example.test/ -o anon.html
curl -s -b jar.txt https://example.test/ -o logged-in.html
diff <(tr '>' '\n' < anon.html) <(tr '>' '\n' < logged-in.html) | head -40
One more header deserves attention while you are here. WordPress sends
Cache-Control: no-cache, must-revalidate, max-age=0, no-store, private on responses
where caching would be wrong, through wp_get_nocache_headers(), and it has included
no-store and private for every user, logged in or not, since 6.8. A
front-end cache that stores those responses anyway is overriding an instruction, and if you find
that happening, the fix is in the cache's configuration rather than in WordPress.
Take a staging site with full-page caching enabled and produce a cache report you could hand to a developer.
Find the cache indicator header and record its exact name. If there is none, stop and write that down as the first finding, because everything after it depends on being able to see the cache's decision.
Then work through the exclusions one at a time. For each of these requests, record whether
the response was a hit or a bypass: a plain anonymous GET, the same URL with a
wordpress_logged_in_ cookie, the same URL with a query string, and the same URL
with a WooCommerce session cookie. For every hit, open the served HTML and look for three
things: a nonce, a first name or user ID, and a cart count.
Finish by comparing an anonymous body with a logged-in body for the homepage. List every difference you find and classify each one as required personalisation or a leak.
Show the solution and the reasoning
The report has two halves, and the first half is about the cache's decision rather than about
WordPress. A cache that bypasses correctly on every probe has made a defensible decision even
if its indicator is missing, and a cache that hits on a logged-in cookie has a bug regardless
of how fast it is. Write the four probe results down as a table with the exact header values,
because "we tested the cache" is not checkable and "an authenticated request returned
X-Cache: HIT" is.
On the second half, the three things to grep for in a served body each have a reason. A nonce in a cached page is a value minted during some earlier request, and the question is whether it is shared between visitors, which you can answer by fetching the same URL twice as a guest and comparing the two nonces. A name or user ID is a direct personalisation leak and needs no interpretation. A cart count tells you whether the fragment was rendered server-side into the shared response or left empty for a client-side request to fill.
When you diff anonymous against logged-in bodies, expect differences: a logged-in request
legitimately gets a different nonce, an admin bar, and frequently different markup around the
comment form. What you are looking for is the opposite result, a difference that should be there
and is not, such as a greeting that reads Hello, guest in both files. That pattern
means the personalised value is being served from a shared entry, and it is the one that
generates support tickets.
Two results are worth not over-reading. A shared guest nonce is not on its own a vulnerability, for the reason in the lesson, so do not file it that way; file it as a value you no longer control, and check whether the same page also renders per-session data next to it. And a cache that bypasses everything has no correctness problem and no benefit, so record the bypass rate alongside the findings, or you will have fixed the site by turning caching off.
Progress
Mark this lesson done and it stays marked in this browser.