Everything is fine until it isn’t: the CEO tries to buy a hoodie, the cart total is wrong, and your “high-performance caching” proudly serves yesterday’s session to today’s customer. Or your lead-gen form submits, “works on my machine,” and then disappears into a cached thank-you page loop in production.
Caching is not the villain. Mis-scoped caching is. The goal is speed without functional bugs: carts stay personal, checkouts stay real-time, forms stay unique, and logged-in sessions don’t become a public broadcast.
A practical mental model: what you can cache safely
WordPress caching is a stack, not a feature. You can have (and often do have) multiple caches at once: browser cache, CDN edge cache, reverse proxy cache (Varnish or NGINX), PHP opcode cache, WordPress “page cache” from a plugin, object cache (Redis/Memcached), and database caches. They each solve a different problem, and they each can break your site in their own charming way.
Cache layers and what they’re good at
- Browser caching (Cache-Control, ETag): great for static assets (CSS/JS/images). Not for HTML that contains personalization.
- CDN caching: great for static assets and sometimes anonymous HTML if you can bypass correctly for sessions and checkout flows.
- Reverse proxy / full-page cache: great for anonymous traffic. Dangerous for anything session-specific unless you get the cookie logic right.
- WordPress page cache plugin: often writes static HTML to disk. Easy to deploy, easy to misconfigure, can fight with proxy/CDN caches.
- Object cache (Redis): caches query results and computed objects. Usually safe for logged-in users and WooCommerce if the plugin plays nice.
- PHP OPcache: speeds PHP execution. Rarely breaks functionality; mostly breaks deploys when you forget invalidation.
The rule that keeps you out of trouble
Cache anonymous GET requests for pages that do not vary by user, location, currency, cart, or authentication state. Everything else gets bypassed (or cached with explicit variation keys you can defend in an incident review).
That’s it. It sounds obvious. It fails in practice because “anonymous” is slippery: a user can be technically not logged in but still have a cart, a currency switcher, or a form nonce that must be unique. Also, WordPress loves cookies. WooCommerce loves cookies. Your marketing suite loves cookies. Cookies are how your cache learns to stop being a cache.
One dry truth: if you can’t explain why a response is cached by pointing to headers, keys, and bypass rules, you don’t have caching—you have roulette.
Short joke #1: If your checkout page is cacheable, congratulations—you’ve invented communal shopping.
What should never be cached (HTML)
These are the non-negotiables for most WordPress sites:
- /wp-admin/ and /wp-login.php
- Any page for logged-in users (unless you have a deliberate “private cache” with per-user keys, which is rare and tricky)
- WooCommerce: /cart/, /checkout/, /my-account/, add-to-cart endpoints, fragments endpoints
- Form endpoints and pages with expiring nonces if you rely on default WordPress nonce behavior
- Anything with CSRF tokens, personalized pricing, inventory counts that must be current, or session-dependent messages
What you usually can cache (HTML)
- Public marketing pages, blog posts, docs pages
- Category/tag archives for anonymous visitors
- Product listing pages (PLPs) for anonymous visitors, if pricing/currency doesn’t vary
- Product detail pages (PDPs) for anonymous visitors, with care around “in stock” messaging
Define “vary” like you mean it
If the HTML changes based on any of these, your cache must either bypass or vary the cache key:
- Cookies (cart, session, consent, AB tests)
- Geo/country (tax, shipping eligibility, content rules)
- Currency and language
- Device type (rarely worth varying on anymore; responsive design usually wins)
- Authorization headers
Interesting facts and historical context (because the past still bills you)
- HTTP caching predates WordPress by years. RFC 2616 (HTTP/1.1) formalized Cache-Control semantics in 1999, and we still argue about “must-revalidate” today.
- WordPress popularized “page cache plugins” early because PHP was slow and shared hosting was cheap. Writing static HTML to disk was a survival tactic, not an aesthetic choice.
- WooCommerce’s cart is cookie-driven for guests. That’s why “not logged in” doesn’t mean “safe to cache.”
- ESI (Edge Side Includes) was an early attempt at caching mixed dynamic pages. It works, but it’s operationally heavy; most WordPress stacks avoid it unless they have a serious platform team.
- Varnish rose to fame because it made HTTP caching a first-class reverse proxy. Its VCL language is powerful, and also a great way to create hard-to-debug bypass rules.
- Cache stampedes were recognized as a reliability problem long before WordPress. Techniques like request coalescing and “stale-while-revalidate” exist because traffic spikes punish origin servers.
- Browsers got stricter about cookies over time. SameSite defaults changed behavior, which can change how session cookies interact with caching layers.
- Many CDNs default to “cache everything” only if you explicitly ask. When people enable it for HTML, they often forget the bypass logic for cart and checkout, and then blame WooCommerce.
How caching breaks carts and forms: failure modes that actually happen
1) Cached HTML leaks session-specific content
The classic: the cart widget shows another user’s items, or the header says “Hi, Alex” to a stranger. This happens when full-page cache keys do not vary by the right cookies and you cache a response that included personalized fragments.
Even if the main cart page is excluded, the cart fragment endpoint or the header mini-cart can be cached incorrectly by a plugin, CDN, or an over-eager reverse proxy rule.
2) Checkout totals are stale
Taxes and shipping can vary by address, country, or even postal code. If you cache checkout HTML or cache XHR responses used to compute totals, you can serve wrong totals. Best-case: the payment gateway rejects it. Worst-case: you undercharge and spend your weekend learning finance the hard way.
3) Forms fail because nonces expire or are shared
WordPress nonces are time-based tokens. Many form plugins embed them in HTML. Cache the HTML too long and the nonce expires, producing errors that look like random “Security check failed” messages. Cache it incorrectly across users and you can create weird replay-like behavior where submissions go to the wrong state machine.
4) Logged-in pages are cached as anonymous (or vice versa)
If your caching layer doesn’t respect cookies like wordpress_logged_in_* or a custom auth cookie, you can cache logged-in pages publicly. Or you can bypass caching for everyone because you set a broad cookie like “consent=true” and your cache treats any cookie as a bypass signal. Both are bad. One is a security incident; the other is a budget incident.
5) “Optimization” fights: plugin cache vs. proxy cache vs. CDN cache
Multiple caches can work together, but only if you decide who is authoritative for HTML. When a page cache plugin sets headers one way, NGINX overrides them, and the CDN ignores them, your debugging becomes interpretive dance.
6) Purge storms and thundering herds
Purging the whole cache on every product update is a common default. On busy stores, this becomes a self-inflicted denial of service: cache cold starts, origin gets slammed, PHP-FPM queue grows, then timeouts, then retries, then more load.
There’s a “paraphrased idea” worth keeping on a sticky note from Werner Vogels (Amazon CTO): paraphrased idea: build systems assuming things fail, and design for recovery over perfection.
Fast diagnosis playbook: find the bottleneck and the bug quickly
This is the playbook when someone says “the cart is wrong” or “forms aren’t submitting” and you need signal fast.
First: confirm whether you’re serving cached HTML
- Check response headers for cache hits/misses and Cache-Control.
- Compare anonymous vs. with cart cookie (or logged-in cookie) to see if the cache varies properly.
- Check CDN vs origin: does the edge serve a cached response even when the origin says no-store?
Second: isolate the layer that is caching
- Bypass CDN (origin host or internal IP) and test again.
- Bypass reverse proxy (hit PHP upstream directly if possible) and test again.
- Disable page cache plugin temporarily (or put site in “development mode” if your plugin supports it) and test again.
Third: validate WooCommerce and form-specific endpoints
- Verify that /cart/, /checkout/, and WooCommerce fragment endpoints are not cached.
- Verify that POST requests are never cached (they shouldn’t be, but don’t trust defaults).
- For forms, verify nonce freshness and that the page containing the nonce isn’t cached beyond its TTL.
Fourth: check the backend pressure
- If you’re seeing cache misses, ensure the origin can handle the miss rate: PHP-FPM queue, database latency, Redis health.
- If you’re seeing cache hits but wrong content, focus on cache key variation and bypass rules, not “more hardware.”
Hands-on tasks: commands, expected output, and what to decide
These tasks are designed for a typical Linux host running NGINX + PHP-FPM, optionally Varnish and Redis, fronted by a CDN. Adjust paths and service names to your stack. Every task includes (a) a command, (b) what the output means, and (c) the decision you make.
Task 1: Inspect cache headers for a public page
cr0x@server:~$ curl -sI https://store.example.com/ | egrep -i 'cache-control|age|expires|etag|x-cache|via|cf-cache-status|server'
server: nginx
cache-control: public, max-age=600
etag: "a1b2c3"
x-cache: HIT
age: 87
via: 1.1 varnish
What it means: You’re seeing explicit caching (public, max-age=600), and a reverse proxy cache hit (x-cache: HIT) with Age increasing. Good for the homepage if it’s anonymous-safe.
Decision: If this is a marketing page: keep it. If the homepage includes personalized cart totals or “recently viewed” for guests: you need to move those widgets client-side or bypass caching for users with relevant cookies.
Task 2: Inspect cache headers for cart and checkout pages
cr0x@server:~$ curl -sI https://store.example.com/cart/ | egrep -i 'cache-control|age|x-cache|cf-cache-status|set-cookie'
cache-control: no-store, no-cache, must-revalidate, max-age=0
x-cache: MISS
set-cookie: woocommerce_items_in_cart=1; path=/; secure; HttpOnly
What it means: Cart is correctly marked uncacheable and is missing cache (which is what you want). The cookie indicates session/cart state.
Decision: If you see public caching or HIT here, treat it as a production bug: add bypass rules for these paths at every caching layer.
Task 3: Verify variation when cookies are present
cr0x@server:~$ curl -sI https://store.example.com/ -H 'Cookie: woocommerce_items_in_cart=1; wp_woocommerce_session_123=abc' | egrep -i 'cache-control|x-cache|age|vary'
cache-control: private, no-store, max-age=0
x-cache: BYPASS
vary: Accept-Encoding
What it means: With cart cookies present, the cache bypasses and the response is private/no-store. That’s a sane baseline.
Decision: If it still says x-cache: HIT, your cache key is ignoring cookies or your bypass logic isn’t firing. Fix before you “optimize” anything else.
Task 4: Confirm CDN behavior vs origin (connect to origin directly)
cr0x@server:~$ curl -sI https://origin.store.example.com/checkout/ | egrep -i 'cache-control|x-cache|age|server'
server: nginx
cache-control: no-store, no-cache, must-revalidate, max-age=0
x-cache: MISS
What it means: Origin is not caching checkout. If the public hostname still returns cached checkout, the CDN is the culprit.
Decision: Add CDN bypass rules for checkout/cart/account paths and for relevant cookies. Do not rely on “respect origin headers” unless you’ve verified it in practice.
Task 5: Find which cache plugin is active (WordPress CLI)
cr0x@server:~$ cd /var/www/store
cr0x@server:~$ sudo -u www-data wp plugin list --status=active
+--------------------------+--------+-----------+---------+
| name | status | update | version |
+--------------------------+--------+-----------+---------+
| woocommerce | active | available | 8.6.1 |
| redis-cache | active | none | 2.5.3 |
| wp-super-cache | active | none | 1.9.4 |
+--------------------------+--------+-----------+---------+
What it means: You have a page cache plugin (wp-super-cache) and an object cache plugin (redis-cache) active.
Decision: Decide who is responsible for full-page caching: the plugin or the reverse proxy. Running both is possible but usually creates “why is this cached?” incidents. Pick one and configure the other to stay in its lane.
Task 6: Check if Redis object cache is actually used
cr0x@server:~$ sudo -u www-data wp redis status
Status: Connected
Client: PhpRedis (v5.3.7)
Redis: 7.0.15
Drop-in: Valid
What it means: Object cache is working. This reduces database load and can let you rely less on aggressive HTML caching for logged-in flows.
Decision: Keep object cache enabled. If object cache is disconnected, fix that first before tightening HTML caching TTLs; otherwise you push load back to MySQL.
Task 7: Verify WooCommerce session cookie presence during browsing
cr0x@server:~$ curl -sI https://store.example.com/product/hoodie/ | egrep -i 'set-cookie|cache-control|x-cache'
cache-control: public, max-age=600
x-cache: HIT
What it means: Product page is cached and does not set cart/session cookies on view. That’s ideal.
Decision: If product pages are setting wp_woocommerce_session_* for everyone, your cache hit rate will collapse. Investigate plugins that start sessions on every page view.
Task 8: Check NGINX fastcgi_cache configuration and bypass logic
cr0x@server:~$ sudo nginx -T 2>/dev/null | egrep -n 'fastcgi_cache|fastcgi_cache_bypass|fastcgi_no_cache|cache_key|set \$skip_cache' | head -n 40
123: fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=WORDPRESS:100m inactive=60m max_size=5g;
181: set $skip_cache 0;
186: if ($request_method = POST) { set $skip_cache 1; }
190: if ($request_uri ~* "/(cart|checkout|my-account)/") { set $skip_cache 1; }
194: if ($http_cookie ~* "woocommerce_items_in_cart|wp_woocommerce_session_|wordpress_logged_in_") { set $skip_cache 1; }
221: fastcgi_cache_bypass $skip_cache;
222: fastcgi_no_cache $skip_cache;
223: fastcgi_cache WORDPRESS;
224: fastcgi_cache_key "$scheme$request_method$host$request_uri";
What it means: This host is using NGINX fastcgi_cache. There is explicit bypass for POST, key WooCommerce paths, and key cookies.
Decision: Ensure the cache key includes query string when relevant (see later). Ensure you bypass on the correct cookies for your site and avoid bypassing on harmless cookies like analytics or consent unless necessary.
Task 9: Test whether query strings are incorrectly collapsing cache entries
cr0x@server:~$ curl -sI "https://store.example.com/?utm_source=test1" | egrep -i 'x-cache|age'
x-cache: HIT
age: 140
What it means: The cached page did not vary by query string, which is usually good for tracking parameters. But it can be bad if your site uses meaningful query parameters (filters, currency, language).
Decision: Strip known marketing parameters at the edge, but vary cache on functional parameters (e.g., ?currency=, ?lang=, filtering). Don’t guess—inventory what your theme and plugins use.
Task 10: Verify Varnish behavior and which cookies cause pass
cr0x@server:~$ curl -sI https://store.example.com/ -H 'Cookie: wordpress_logged_in_abc=1' | egrep -i 'x-cache|via|set-cookie|cache-control'
via: 1.1 varnish
x-cache: MISS
cache-control: private, no-store, max-age=0
What it means: Logged-in cookie triggers a miss/bypass and private response.
Decision: Confirm Varnish is not caching authenticated pages. If you see HIT here, stop and fix VCL; you may be leaking private content.
Task 11: Check PHP-FPM pressure during cache misses
cr0x@server:~$ sudo ss -lntp | egrep 'php-fpm|:9000'
LISTEN 0 4096 127.0.0.1:9000 0.0.0.0:* users:(("php-fpm8.2",pid=1211,fd=9))
What it means: PHP-FPM is listening locally. That’s normal.
Decision: Next, check the pool status and backlog. If backlog grows during traffic, your cache bypass rules might be too broad or your TTL too short.
Task 12: Inspect PHP-FPM pool status page (if enabled)
cr0x@server:~$ curl -s http://127.0.0.1/php-fpm-status | egrep -i 'listen queue|idle processes|active processes|max children reached'
listen queue: 0
idle processes: 12
active processes: 3
max children reached: 0
What it means: No queue, plenty of idle workers. Origin is healthy.
Decision: If listen queue climbs and max children reached increments, you have an origin capacity issue. Increase FPM capacity, reduce miss rate, or both.
Task 13: Check MySQL latency quickly
cr0x@server:~$ mysqladmin -uroot -p ping; mysqladmin -uroot -p status
mysqld is alive
Uptime: 183204 Threads: 42 Questions: 23801984 Slow queries: 17 Opens: 231 Flush tables: 1 Open tables: 1024 Queries per second avg: 129.9
What it means: MySQL is up; slow queries exist but not obviously exploding from this snapshot.
Decision: If slow queries spike during cache purges, you need to reduce purge scope, add object cache, or tune DB indexes/queries. Don’t “fix” it by caching checkout.
Task 14: Verify that POST is not cached by an intermediary
cr0x@server:~$ curl -s -o /dev/null -D - -X POST https://store.example.com/wp-admin/admin-ajax.php | egrep -i 'cache-control|x-cache|status|via'
HTTP/2 400
cache-control: no-store, no-cache, must-revalidate, max-age=0
via: 1.1 varnish
x-cache: MISS
What it means: POST response is not cached (and you got a 400 because no payload). That’s fine for this test.
Decision: If you see a cache HIT on a POST, you have a serious proxy/CDN misconfiguration. Fix immediately; caching POST breaks more than carts—it breaks reality.
Task 15: Check WordPress cron and background jobs (cache purge load)
cr0x@server:~$ sudo -u www-data wp cron event list --fields=hook,next_run,recurrence | head
+------------------------------+---------------------+------------+
| hook | next_run | recurrence |
+------------------------------+---------------------+------------+
| wp_version_check | 2025-12-27 03:12:00 | twice_daily|
| woocommerce_cleanup_sessions | 2025-12-27 02:45:00 | daily |
| wp_scheduled_delete | 2025-12-27 02:10:00 | daily |
+------------------------------+---------------------+------------+
What it means: WooCommerce session cleanup and other cron tasks run regularly. Some caching plugins hook into cron for purge/preload.
Decision: If you see purge/preload jobs too frequent, throttle them. Preloading can turn into a self-inflicted crawler that steals capacity from real users.
Configuration patterns that work: plugin, server, CDN, and cookies
Decide who caches HTML
Pick one primary HTML cache. My opinionated default for production:
- Reverse proxy cache (NGINX fastcgi_cache or Varnish) for anonymous HTML.
- CDN for static assets, optionally anonymous HTML if you have disciplined bypass rules.
- Object cache (Redis) always, for both anonymous and logged-in.
- WordPress page cache plugin only if you don’t control the server layer. If you do control it, keep plugins minimal.
The more places you can accidentally cache a checkout page, the more likely you will, eventually, cache a checkout page.
Cookie-based bypass: the heart of WooCommerce safety
WooCommerce sets cookies that signal cart/session state. The exact names vary, but you’ll commonly see:
woocommerce_items_in_cartwoocommerce_cart_hashwp_woocommerce_session_*wordpress_logged_in_*(for WordPress auth)
At the reverse proxy or NGINX cache layer, your bypass rule should typically trigger when any of those are present. That keeps HTML caching limited to truly anonymous browsing.
Path-based exclusions: still necessary
Even with cookie bypass, exclude sensitive paths explicitly. Because humans will test checkout without a cart, without cookies, and declare it “safe.” Then a real shopper shows up with state and the cache does something creative.
Common exclusions:
/cart/,/checkout/,/my-account//?wc-ajax=*and WooCommerce AJAX endpoints/wp-admin/,/wp-login.php- Form endpoints, especially if you’re using admin-ajax or REST routes for submissions
Keep “vary” intentional (and small)
If you vary by too much, you destroy the cache hit rate and then wonder why performance didn’t improve. If you vary by too little, you leak data and break flows.
Good reasons to vary:
- Accept-Encoding (handled automatically)
- Country if your site changes tax/shipping/legal content
- Currency if prices change
- Language if content changes
Bad reasons to vary:
- Random marketing cookies
- Consent state (unless it changes the HTML substantially)
- User agent (unless you serve completely different HTML)
Use “stale” strategies to prevent stampedes
If your reverse proxy supports it, allow serving stale content for anonymous pages while revalidating in the background. The user gets a fast response; the origin doesn’t get stampeded when a popular page expires.
But do not apply stale content to transactional endpoints. Nobody wants stale checkout.
Forms: treat nonces like milk, not honey
When a form includes a nonce or CSRF token in the HTML, you have three viable options:
- Don’t cache the form page (simple, safe, can cost performance).
- Cache the page but render nonce dynamically via AJAX or edge-side includes (more complex, scalable).
- Short TTL for the form page, and accept that some long-open tabs will fail (a business decision; log and monitor errors).
Short joke #2: A cached nonce is like a photocopied hotel keycard—technically a card, functionally a complaint.
CDN: cache HTML only when you can bypass precisely
CDNs are excellent at static assets. For HTML, proceed only if:
- You can bypass based on cookies and paths.
- You can purge selectively (by URL or tag) rather than “purge everything.”
- You can see debug headers (cache status, cache key or variation) during incidents.
If your CDN rule is “cache everything” with a 1-hour TTL and a hand-wavy bypass, you will eventually cache something you didn’t mean to. That’s not cynicism. That’s time.
Object cache is not a substitute for page cache (and that’s good)
Redis object cache speeds up WordPress execution without serving the wrong user’s HTML. It’s typically the least risky performance win you can deploy on WooCommerce-heavy sites, because it doesn’t shortcut the request/response logic at the HTTP layer.
Use it to reduce the temptation to cache dynamic HTML.
Three corporate mini-stories from the caching trenches
Mini-story 1: The incident caused by a wrong assumption
The company: a mid-market retailer with a “modernized” WordPress stack. New reverse proxy cache in front of PHP-FPM. A sprint demo showed the homepage loading in under 100ms. Everyone applauded. Someone used the phrase “basically solved performance.”
The wrong assumption was simple: “If the user isn’t logged in, the response is safe to cache.” It’s the kind of sentence that sounds like engineering until you remember WooCommerce exists.
On Monday, customer support reported shoppers seeing “items already in your cart” on first visit. A few screenshares later, it got worse: the mini-cart dropdown showed products that belonged to other sessions. Not payment data, not addresses—but still a privacy leak and a credibility hit.
Debugging showed the reverse proxy ignored WooCommerce cookies entirely. Guests were “anonymous,” but their cart state was tracked by wp_woocommerce_session_*. The cache key was only host + uri. The first person to add a product effectively “poisoned” the cached HTML for everyone who hit that page next.
The fix was boring: bypass cache if WooCommerce session/cart cookies are present, and exclude cart/checkout paths explicitly. The follow-up was more important: they wrote a one-page “what makes a response user-specific” checklist and made it part of every cache-related change review.
Mini-story 2: The optimization that backfired
The company: a subscription service with heavy content marketing and a WooCommerce checkout for add-ons. They wanted higher cache hit rates at the CDN, so they decided to “normalize” cookies: strip most cookies at the edge to make more requests cacheable.
The idea sounded clever. It was also incomplete. One of the stripped cookies was a currency selector cookie set by a plugin. Another was a country selection cookie used for tax display. They did not vary cache by country because “the CDN geo header should handle it,” except they didn’t wire it into the cache key. And the CDN, being a machine, did exactly what it was told.
Result: Canadian visitors saw US pricing on product pages. Some moved to checkout and saw totals flip. Others saw “tax included” messaging that didn’t apply. Conversion dropped, and support tickets spiked. The incident was classified as a “performance change regression,” which is corporate language for “we broke money while chasing milliseconds.”
The recovery plan was disciplined: they rolled back the cookie stripping, then reintroduced it with allowlists. They documented which query params and cookies were “functional” and must remain in cache variation. They also added automated checks: a scripted curl that compares headers and a couple of representative pages with and without currency/country indicators.
Eventually they got CDN HTML caching working for anonymous marketing pages only, with strict bypass on anything commerce-related. Cache hit rate was lower than the original dream. Revenue stopped doing weird things. A fair trade.
Mini-story 3: The boring but correct practice that saved the day
The company: B2B SaaS running WordPress for marketing and documentation, with forms tied to their CRM. They had a reverse proxy cache and a CDN, and they were planning a campaign that would spike traffic.
Instead of “turning caching up,” they did the boring thing: they wrote down every URL pattern and endpoint that must never be cached, then they tested it in staging with production-like headers. They also created a “cache debug header” standard: origin would emit X-Cache-Bypass-Reason when bypassing, and the reverse proxy would emit X-Cache: HIT/MISS/BYPASS.
During the campaign, performance was fine—until forms started failing for a subset of users. The quick reaction would have been to blame the form plugin or the CRM. Instead, they followed their own playbook: check headers on the form landing page. It was being cached for 30 minutes at the CDN due to a mismatched rule set deployed the day before.
Because they had consistent debug headers, it was obvious within minutes which layer was at fault. They adjusted the CDN rule to bypass caching for the form pages and any response setting specific cookies. Errors stopped. The campaign continued. Nobody had to invent a story for leadership about “intermittent third-party issues.”
They didn’t get applause for the header standard. They got something better: silence on the incident channel.
Common mistakes: symptom → root cause → fix
1) Cart shows items from another user
Symptom: Mini-cart or cart page shows unexpected items; users report “ghost cart.”
Root cause: Full-page cache does not bypass on WooCommerce session/cart cookies, or caches fragments endpoint.
Fix: Bypass cache on woocommerce_items_in_cart, woocommerce_cart_hash, wp_woocommerce_session_*. Exclude /cart/, /checkout/, /?wc-ajax=*. Ensure fragments endpoint is not cached at CDN or proxy.
2) Checkout totals change unexpectedly or are wrong
Symptom: Shipping/tax changes between steps, or totals mismatch payment gateway.
Root cause: Cached checkout HTML or cached AJAX responses used to calculate totals; missing variation by country/currency.
Fix: Never cache checkout HTML. Bypass cache for country/currency selection flows. For CDNs, disable caching for WooCommerce AJAX and checkout endpoints entirely.
3) “Security check failed” on forms
Symptom: Nonce validation errors, intermittent form failure, especially after a page sits open.
Root cause: Cached page contains an expired nonce; TTL too long; shared cache across users.
Fix: Exclude form pages from cache or generate nonces dynamically. Shorten TTL only if you accept long-tab failures; monitor error rates.
4) Logged-in users see cached anonymous pages (or vice versa)
Symptom: Admin bar missing, account page looks logged out, or anonymous users see private content.
Root cause: Cache does not respect wordpress_logged_in_* cookies; Authorization header ignored; inconsistent cache key.
Fix: Bypass on WordPress auth cookies and Authorization header. Validate with curl using cookies. If you need caching for logged-in pages, implement per-user variation explicitly (rare; test heavily).
5) Cache hit rate collapses after adding analytics/consent tooling
Symptom: Suddenly everything is a cache MISS; origin load spikes.
Root cause: Cache bypass is triggered by “any cookie” or by a broad regex that matches consent/analytics cookies.
Fix: Switch from “bypass if any cookie exists” to an allowlist/denylist model: bypass only on functional cookies (cart/session/auth). Strip irrelevant cookies from cache consideration when safe.
6) Purging causes outages or timeouts
Symptom: Site slows down after content updates; spikes in 5xx; database CPU climbs.
Root cause: Purge-all strategy; preload crawler too aggressive; cache stampede at expiry.
Fix: Purge selectively; implement stale-while-revalidate where possible; rate-limit preloading; ensure origin has capacity for miss bursts.
7) Product pages show wrong language or currency
Symptom: Visitors see mismatched language/currency compared to their selection.
Root cause: CDN/proxy caches HTML without varying on language/currency cookie or header; query params stripped incorrectly.
Fix: Vary cache key on the functional selector (cookie/header/query param). Or bypass caching for pages where this can’t be done cleanly.
8) “It works when I bypass cache”
Symptom: Bug disappears with cache disabled; reappears with cache enabled.
Root cause: The cache is masking a stateful dependency (nonce/session), or caching an error response.
Fix: Prevent caching of error statuses; ensure bypass on stateful cookies; tighten Cache-Control for sensitive pages; verify layer-by-layer with headers.
Checklists / step-by-step plan for safe caching
Step 1: Inventory what must be dynamic
- List transactional URLs: cart, checkout, account, login, admin.
- List form pages and submission endpoints (admin-ajax, REST routes).
- List personalization features on anonymous pages (recently viewed, geo pricing, currency/language switchers).
Step 2: Choose your caching authority
- If you control NGINX/Varnish: use that for HTML caching and keep WordPress page cache plugins off (or set them to only manage browser cache/static optimization).
- If you’re on constrained hosting: use a reputable cache plugin, and keep CDN HTML caching conservative.
Step 3: Implement hard exclusions
- Exclude
/wp-admin/,/wp-login.php. - Exclude
/cart/,/checkout/,/my-account/. - Exclude WooCommerce AJAX endpoints (
wc-ajax) and fragments. - Exclude form endpoints and pages with nonces if you can’t render them dynamically.
Step 4: Implement cookie-based bypass
- Bypass on
wordpress_logged_in_*and any auth/session cookies you use. - Bypass on WooCommerce cart/session cookies:
woocommerce_items_in_cart,woocommerce_cart_hash,wp_woocommerce_session_*. - Do not bypass on “any cookie.” Use targeted matching.
Step 5: Decide variation keys for language/currency/geo
- If currency/language changes HTML, vary the cache key or bypass.
- Strip marketing query params from cache key; keep functional ones.
Step 6: Set TTLs like an adult
- Marketing/blog pages: 5–30 minutes is a common starting point.
- Product pages: shorter TTL if inventory/pricing changes often; otherwise moderate TTL with purge on update.
- Transactional pages: no-store/no-cache.
Step 7: Implement selective purging
- Purge only the changed URLs when a post/product updates.
- Avoid purge-all except during deploys or emergencies.
- Rate-limit purge requests to protect caches and origin.
Step 8: Add observability for caching
- Add
X-Cacheand, ideally,X-Cache-Bypass-Reasonheaders at the proxy layer. - Track cache hit ratio, origin response time, and 5xx rates.
- Create a “cart/checkout canary” synthetic test that runs every few minutes and alerts if those pages become cacheable.
Step 9: Test with real cookies and flows
- Anonymous browse with no cookies.
- Anonymous with cart cookie present.
- Logged-in user.
- Country/currency variations if you support them.
- Form submit from a page left open for 30+ minutes (nonce expiry scenario).
FAQ
1) Can I cache WooCommerce pages at all?
You can cache some WooCommerce pages for anonymous users: product listings and product detail pages are often fine. Do not cache cart, checkout, account, or WooCommerce AJAX endpoints. The safety boundary is session state, not “is it a store page.”
2) Why does my cache hit rate drop to near zero when I enable WooCommerce?
Usually because something is setting session/cart cookies too early (on product views, homepage, or even every page). Once a cookie is present, your cache bypass logic may skip caching for that user. Find which plugin/theme starts sessions, and stop it.
3) Should I bypass cache if any cookie exists?
No. That’s the fast route to turning your cache into a decorative configuration file. Bypass only for functional cookies (auth, cart, session, currency/language if you can’t vary safely).
4) My CDN says it “respects origin headers.” Why is it caching checkout anyway?
Because “respects” has footnotes. You might have a page rule overriding it, or the CDN might treat some statuses differently, or you’re caching by default and only bypassing on paths you forgot. Verify by comparing headers from origin vs edge and by testing with cookies.
5) What’s the safest caching setup for WordPress + WooCommerce?
Conservative full-page caching for anonymous pages only (reverse proxy or plugin), strict bypass for cart/checkout/account/auth and WooCommerce cookies, plus Redis object cache. Cache static assets aggressively at CDN/browser.
6) Do I need Varnish if I already have a WordPress cache plugin?
Not necessarily. Varnish can be excellent, but it’s another moving part. If you control the server and want predictable behavior at high scale, Varnish or NGINX fastcgi_cache is often cleaner than a plugin. If you don’t control the server, a plugin may be the practical option.
7) Why do forms break only sometimes?
Because caching failures are time-dependent: nonce expiration windows, cache TTL, and whether a user hits a cached copy or triggers a fresh render. Intermittent form failures are a strong hint that HTML with a nonce is being cached too long.
8) Is object caching (Redis) risky for WooCommerce?
Generally, it’s less risky than full-page caching because it doesn’t serve one user’s HTML to another. The main risks are operational: Redis availability, memory sizing, and eviction behavior. Monitor it like any dependency.
9) How do I know which layer served the cached response?
Headers. Add them if you don’t have them. CDN cache status headers, reverse proxy X-Cache, and origin headers like Cache-Control tell you where to look. If you can’t tell, you can’t debug under pressure.
10) Can I cache logged-in users safely?
Possible, but rarely worth it for WordPress unless you build per-user cache keys and accept complexity. For most sites, invest in object cache, efficient queries, and PHP-FPM tuning instead. Keep logged-in HTML dynamic.
Next steps you can do this week
If your carts or forms are breaking, don’t start by changing TTLs. Start by proving which layer is serving cached content and whether it varies by the right state.
- Add cache debug headers at your reverse proxy (HIT/MISS/BYPASS and a bypass reason). You will thank yourself later.
- Implement strict exclusions for cart/checkout/account/login/admin and WooCommerce AJAX endpoints at every caching layer you operate.
- Implement cookie-based bypass for WooCommerce cart/session cookies and WordPress logged-in cookies.
- Audit cookies and query params used for currency/language/geo and decide: vary, bypass, or redesign.
- Deploy Redis object cache (if you haven’t) and confirm it’s actually active.
- Create a synthetic test that fetches /cart/ and /checkout/ and asserts
Cache-Control: no-storeand no cache HIT headers. - Stop purge-all behavior unless you’re in an emergency. Purge surgically, and use stale strategies for anonymous pages if your proxy supports it.
Caching is a power tool. Treat it like one. Wear the safety glasses: headers, bypass rules, and tests that run when you’re not watching.