Page caching is the single biggest speed win for a WooCommerce store and the single easiest way to break it. Cache the product pages and the store flies; cache the cart and a shopper sees someone else's basket; cache the checkout and every order fails with "session expired". WooCommerce marks these pages as not cacheable, and caching plugins listen. The problem is every layer that does not: a host's server cache, a CDN with a "cache everything" rule, a Varnish in front of nginx. This guide shows what goes wrong, how WooCommerce signals it, and the exclusion for each layer. Plan an hour and a test purchase in two browsers.
Quick answer
- Exclude
/cart/,/checkout/and/my-account/(and every translated slug) from every cache layer: plugin, host, CDN, reverse proxy. - Bypass the cache for any request that carries a
woocommerce_items_in_cart,woocommerce_cart_hash,wp_woocommerce_session_*orwordpress_logged_in_*cookie. - Never cache
?wc-ajax=requests or/wp-json/wc/store/. - WooCommerce already sends
Cache-Control: no-cache, must-revalidate, max-age=0on those pages; a layer that ignores that header needs an explicit rule. - Test with
curl -sI https://example.com/checkout/twice: noageheader, noHITstatus. - The WooCommerce checker fetches
/cart/and/checkout/and reports which one came from a cache, with the header that proves it.
Why a cached cart matters
A page cache stores the HTML of one response and serves it to everyone who asks for the same URL until it expires. For a product page that is exactly right. For the cart it is wrong in the worst way: the first shopper to load /cart/ after a purge fills the cache with their basket, and every shopper for the next hour sees three pairs of the first shopper's shoes. On the checkout, the cached page contains a security token (a nonce) that was valid for the first shopper's session. When the next shopper submits the form, WooCommerce rejects the token, the page says "Sorry, your session has expired" or "We were unable to process your order", and the shopper leaves. Payment gateways that render inside the checkout fail in stranger ways, because their tokens are cached too.
The account pages leak. A cached /my-account/orders/ shows one customer's order history to the next visitor. That is a privacy incident, not a bug.
The damage is invisible in your own testing because you are logged in, and every cache skips logged-in users. It shows up as a trickle of support emails, a rising abandoned-checkout rate, and a report finding.
How WooCommerce signals it
WooCommerce does three things on the cart, checkout and account pages that a well-behaved cache respects:
- It defines the PHP constant
DONOTCACHEPAGE. Every major caching plugin checks it and skips the page. - It sends
Cache-Control: no-cache, must-revalidate, max-age=0(WordPress'snocache_headers()), plusExpiresin the past. - It sets cookies as soon as a basket has something in it:
woocommerce_items_in_cart=1,woocommerce_cart_hash, and a session cookie namedwp_woocommerce_session_followed by a hash. A request with those cookies is personal, and caching plugins bypass it.
A caching plugin runs inside WordPress and sees all three. A host cache, a CDN or Varnish runs in front of WordPress and sees only the second and third, in the response headers and the request cookies. Most of them respect Cache-Control by default. The failures come from settings that override it: Cloudflare's "Cache everything" with an Edge TTL that ignores origin headers, a host panel's "cache all pages" switch, a Varnish configuration that strips cookies.
How getReport checks it
Once the page reveals WooCommerce, the report fetches two more addresses, /cart/ and /checkout/, as an anonymous visitor with an empty basket, and reads the response headers. It counts the page as cached when a cache status header contains "hit" (x-cache, cf-cache-status, x-litespeed-cache, x-nginx-cache, x-proxy-cache, x-varnish-cache, x-kinsta-cache, x-sg-cache, x-wpe-cache and a few others), or when the response carries an age above 0 together with a public Cache-Control that does not say no-store or private. It does not add anything to a basket and submits nothing.

The evidence header is the clue to the layer: cf-cache-status: HIT is Cloudflare, x-litespeed-cache: hit is LiteSpeed, x-kinsta-cache, x-sg-cache and x-wpe-cache are the named hosts, x-varnish-cache or an age header with no vendor status usually means Varnish or nginx in front. The addresses are fixed; if your cart lives at /kosarica/ and /cart/ answers 404, the finding passes without having seen the real cart, so test the real slug by hand as described under Verify.
Two related findings from the same report:
The first is the opposite goal: static files (images, CSS, scripts) should be cached for a long time, and excluding the cart must not switch that off. The second lists cookies set without a SameSite attribute. It matters here because the checkout depends on cookies surviving a round trip to a payment provider: SameSite=Lax lets the session cookie travel with the top-level redirect back from the gateway, while Strict drops it and the shopper lands on an empty cart after paying. Say Lax explicitly rather than trusting browser defaults.
Step by step
1. List the real slugs
WooCommerce → Settings → Advanced → Page setup shows which pages are the Cart, Checkout and My account pages. Their slugs are what you exclude. On a translated store each language has its own set (WPML and Polylang register them per language); collect all of them. Add /checkout/order-received/ and /checkout/order-pay/ if your exclusions match exact paths rather than prefixes.
2. The caching plugin
Most plugins add these exclusions when they detect WooCommerce. Confirm rather than assume:
- WP Rocket: automatic. Settings → WP Rocket → Advanced Rules → Never Cache URL(s) shows nothing for them because they are excluded in code; add translated slugs there if they are missing.
- LiteSpeed Cache: Cache → Excludes → Do Not Cache URIs (
/cart/,/checkout/,/my-account/, one per line, matched as prefixes) and Do Not Cache Cookies (woocommerce_items_in_cart,woocommerce_cart_hash,wp_woocommerce_session_). Keep Cache Logged-in Users off. - W3 Total Cache: Performance → Page Cache → Never cache the following pages: the three paths; Rejected cookies: the three cookie names.
- WP Super Cache: Settings → WP Super Cache → Advanced → Rejected URL Strings, plus the cookies under Rejected Cookies.
Also exclude wc-ajax= as a query string, where the plugin has such a list.
3. The host cache
Managed WordPress hosts (Kinsta, WP Engine, SiteGround, Cloudways and others) run a server-level cache with a WooCommerce preset that already skips the three pages and the cookies. Open the host's caching panel and confirm the preset is on, or add the paths to its exclusion list. On a plain VPS with nginx FastCGI cache, the rules are yours to write:
# nginx: inside the server block, before the PHP location
set $skip_cache 0;
# Cart, checkout and account pages, plus AJAX and the Store API
if ($request_uri ~* "/(cart|checkout|my-account)(/|$)") { set $skip_cache 1; }
if ($args ~* "wc-ajax=") { set $skip_cache 1; }
if ($request_uri ~* "^/wp-json/wc/store/") { set $skip_cache 1; }
# Anyone with a basket, a session or a login
if ($http_cookie ~* "woocommerce_items_in_cart|woocommerce_cart_hash|wp_woocommerce_session_|wordpress_logged_in_") {
set $skip_cache 1;
}
location ~ \.php$ {
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
# … fastcgi_pass and the rest of the PHP block
}fastcgi_cache_bypass stops serving from the cache; fastcgi_no_cache stops storing. Both are needed, otherwise a shopper's cart response gets stored for the next anonymous visitor.
4. Varnish
Varnish caches whatever it is told to and strips cookies in many default configurations, which is how it ends up serving carts. In default.vcl:
sub vcl_recv {
# WooCommerce: never cache cart, checkout, account, AJAX or the Store API
if (req.url ~ "^/(cart|checkout|my-account)(/|$)" ||
req.url ~ "wc-ajax=" ||
req.url ~ "^/wp-json/wc/store/") {
return (pass);
}
# Anyone with a basket, a session or a login gets a fresh page
if (req.http.Cookie ~ "(woocommerce_items_in_cart|woocommerce_cart_hash|wp_woocommerce_session_|wordpress_logged_in_)") {
return (pass);
}
}
sub vcl_backend_response {
# Respect what WooCommerce says
if (beresp.http.Cache-Control ~ "no-cache|no-store|private") {
set beresp.uncacheable = true;
return (deliver);
}
}return (pass) sends the request to the backend and does not store the answer. Put these checks before any rule that removes cookies, or the cookie test never matches.
5. The CDN
Cloudflare's default caches static files only; HTML is not cached unless you tell it to with a Cache Rule or APO. APO knows WooCommerce and bypasses the cart cookies. A hand-made "cache everything" rule does not, so add a bypass rule above it: Caching → Cache Rules → Create rule, with the expression
(http.request.uri.path contains "/cart")
or (http.request.uri.path contains "/checkout")
or (http.request.uri.path contains "/my-account")
or (http.request.uri.query contains "wc-ajax=")
or (http.request.uri.path contains "/wp-json/wc/store/")
or (http.cookie contains "woocommerce_items_in_cart")
or (http.cookie contains "wp_woocommerce_session_")
or (http.cookie contains "wordpress_logged_in_")and Cache eligibility set to "Bypass cache". Replace or extend the paths with your translated slugs. If your caching rule sets an Edge TTL that ignores origin headers, the bypass rule is the only thing protecting the checkout; without it, WooCommerce's no-cache header is overruled.
Other CDNs (Bunny, Fastly, KeyCDN) have the same two knobs under different names: a path or query rule and a cookie rule. Configure both.
6. Purge from the inside out
Purge the caching plugin, then the host cache, then the CDN. A stale cart in the host cache would otherwise refill the CDN. Then test.
Platform notes
Block checkout
The Cart and Checkout blocks (the default on new stores) render their contents from /wp-json/wc/store/ in the browser. The page shell is less sensitive than the classic shortcode checkout, but the Store API responses are as personal as the classic page and must bypass the cache; the rules above cover the path.
Multilingual stores
The translated slugs are the classic gap: /cart/ is excluded, /kosarica/ is cached. Every rule above takes a list; put every language's slugs in it. The report only probes /cart/ and /checkout/, so a translated store needs the manual test below.
Two caching plugins
One excludes the checkout, the other caches it anyway, and each purges only its own copy. Keep one, as in Two caching plugins: why it breaks and which one to keep.
Verify
- Re-run the WooCommerce checker. The finding reads "Cart and checkout bypass the page cache", and the table shows both addresses with
cache-control: no-cache, must-revalidate, max-age=0and no cache hit. - From a terminal, twice in a row, with your real slug:
curl -sI https://example.com/checkout/ | grep -iE "^(cache-control|age|x-cache|cf-cache-status|x-litespeed-cache):"No age line, and any status header reads BYPASS, DYNAMIC or MISS both times.
- Two browsers, or one normal and one private window: add a different product in each, open
/cart/in both. Each shows only its own basket. Check out in one; the other's basket is unchanged. - After a purchase,
/checkout/order-received/…in the other browser shows nothing of the first order. - Static files still cache: the cache-lifetime finding still passes, and a product image request shows
cache-control: public, max-age=31536000or similar, as explained in Cache-Control for humans.
Common mistakes
- Excluding the paths but not the cookies. The cart page is safe, but the header mini-cart on every other page is cached with the first shopper's count. Add the cookie rule at the same layer.
- Bypassing on any cookie. "Bypass if the request has a cookie" turns caching off for everyone with a consent banner or an analytics cookie. Match the WooCommerce and login cookies by name.
- Caching
wc-ajaxor the Store API. The pages are excluded but the requests that fill them are shared. Exclude the query string and the API path. - Fixing only the plugin. The evidence header said
cf-cache-status: HIT; the plugin was never the problem. Fix the layer the header names. - Purging in the wrong order. CDN first, then host, and the CDN refills from the stale host copy. Inside out, every time.
- Testing while logged in. Every cache skips logged-in users, so the checkout looks fine to you. Test in a private window or a second browser.