WooCommerce at scale: caching the cart and checkout without breaking them (Varnish and Redis vs WP Engine and Cloudways)
Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.
Never cache the cart, checkout, My Account, the Store API or any response that sets a cookie. Cache anonymous product and category pages for a few minutes, bypass the page cache for every shopper who has a cart or a login, and put a Redis object cache under the uncached requests. Treat a cached Set-Cookie header as the serious bug: it hands one shopper's session, cart and checkout details to the next. Self-managed Varnish works if someone owns its rules; WP Engine (EverCache for WooCommerce, from $150 a month) and Cloudways (Varnish rules preset for WooCommerce apps, Redis included, from $11 a month) make those rules the host's job. Whichever you choose, run a cart isolation test after every cache change.
A page cache is what lets a WooCommerce store survive a traffic spike: thousands of shoppers get the same product page from memory, and PHP never runs. The catch is that WooCommerce's most valuable pages are different for every shopper. The cart, the checkout and every page viewed by someone with items in their cart depend on a session cookie. Cache them by URL and one shopper sees another's cart. Bypass them and the traffic that actually buys hits PHP and the database, right when the sale starts. This guide shows where the line goes, how to enforce it on your own Varnish or Redis setup or on a managed host, and how to prove it holds.
Cache exclusions follow WooCommerce's developer documentation and its Store API docs; WP Engine and Cloudways features and prices come from their pricing and product pages, checked in October 2026. The cart isolation test was run against a mock WooCommerce origin behind two local caching proxies, one following the rules in this guide and one with a common misconfiguration. The Varnish configuration was not run against a real Varnish server; test it on staging with the script in section 4 before production. Capacity and cost figures are our calculations from stated assumptions, not benchmarks.
1. Why the cart and checkout can't be cached
A page cache stores one response per URL and serves it to everyone. WooCommerce doesn't put the cart in the URL. It keeps it in the database, keyed by the wp_woocommerce_session_* cookie, and sets woocommerce_items_in_cart and woocommerce_cart_hash once something is added. So /cart/ is one URL with a different answer for every shopper. A cache that ignores the cookies gets the answer wrong for all but one of them.
The page that shows the wrong cart is the visible failure. The dangerous one is quieter: a cached response that sets a cookie.
| What gets cached by mistake | What shoppers see | How bad |
|---|---|---|
A response with Set-Cookie (add to cart, first visit after a plugin sets a cookie) | Two shoppers share one session: one cart, one checkout form | Severe: personal data exposure, wrong orders |
| The cart, checkout or My Account page | Another shopper's items, totals or account links | Severe |
The Store API (/wp-json/wc/store/v1/cart) or wc-ajax calls | Block-based cart and mini cart show someone else's contents | Severe, and easy to miss: the page itself is excluded, the API isn't |
| Product pages for shoppers who already have a cart | Mini cart shows 0 items; member or customer-group prices show retail prices | Conversion loss, B2B pricing errors |
| Product pages cached longer than about 12 hours | Add to cart or search fails, because the WordPress nonce in the page has expired | Intermittent, hard to reproduce |
| Pages varied by country or currency, cached once | Wrong currency or tax display | Pricing errors in other markets |
2. The rule: what to cache, what to bypass
| Request | Page cache | Why |
|---|---|---|
| Product, category and content pages for anonymous visitors | Cache, about 10 minutes, purge on price or stock change | This is the traffic a cache exists for |
| Any request carrying a cart, session or login cookie | Bypass | Mini cart, prices and stock messages depend on the session |
/cart/, /checkout/, /my-account/ | Bypass | WooCommerce's docs list them as pages that must stay dynamic |
?add-to-cart=, wc-ajax=, /wp-json/, /wc-api/, admin-ajax.php | Bypass | Actions and APIs, not pages |
Any response with Set-Cookie | Never store | The next visitor would receive the cookie |
| Images, CSS, JS | Cache long, at the CDN | Same for everyone; versioned file names make purging unnecessary |
The consequence that matters for capacity: once a shopper adds something to the cart, every page they view is uncached. During a sale, the share of shoppers with a cart rises, so the cache hit rate falls exactly when traffic peaks. Size the server for the uncached traffic, not the cached traffic. The arithmetic is simple: PHP workers needed ≈ uncached requests per second × average uncached response time.
3. Self-managed: Varnish rules and a Redis object cache
If you run your own server, Varnish in front of the web server is the classic setup. Its default (built-in) logic already refuses to cache responses with cookies, which also means it caches almost nothing on a WordPress site that sets analytics cookies. The usual "fix" found in forum posts is to strip cookies everywhere, and that is how carts end up shared. This VCL keeps the safety and gets the hit rate back by being specific:
vcl 4.1;
# Varnish in front of WooCommerce: cache anonymous catalog pages, never carts or sessions.
# Change the slugs if your cart, checkout or account pages live elsewhere.
backend default {
.host = "127.0.0.1";
.port = "8080";
}
acl purgers {
"127.0.0.1";
}
sub vcl_recv {
if (req.method == "PURGE") {
if (client.ip !~ purgers) {
return (synth(405));
}
return (purge);
}
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
# Private pages and endpoints: cart, checkout, account, admin, REST (Store API) and AJAX
if (req.url ~ "^/(cart|checkout|my-account|wp-admin|wp-login\.php|wp-json|wc-api)(/|\?|$)"
|| req.url ~ "[?&](add-to-cart|wc-ajax)=") {
return (pass);
}
# A shopper with a cart or a login gets fresh pages everywhere: the mini cart,
# customer-group and member prices all depend on the session
if (req.http.Cookie ~ "(wp_woocommerce_session_|woocommerce_items_in_cart|woocommerce_cart_hash)"
|| req.http.Cookie ~ "(wordpress_logged_in_|wp-postpass_)") {
return (pass);
}
# Everyone else is anonymous: drop analytics cookies so they all share one cache entry
unset req.http.Cookie;
# Tracking parameters don't change the page, so they shouldn't split the cache
set req.url = regsuball(req.url, "(?<=[?&])(utm_[a-z]+|gclid|fbclid|msclkid)=[^&]*&?", "");
set req.url = regsub(req.url, "[?&]+$", "");
return (hash);
}
sub vcl_backend_response {
# Never store a response that sets a cookie: the next shopper would receive this
# shopper's session cookie, and with it their cart. Don't "fix" a low hit rate by
# unsetting Set-Cookie here.
if (beresp.http.Set-Cookie || bereq.url ~ "^/(cart|checkout|my-account)(/|\?|$)") {
set beresp.uncacheable = true;
set beresp.ttl = 120s;
return (deliver);
}
if (beresp.http.Content-Type ~ "text/html") {
# Pages carry WordPress nonces, valid for 12-24 hours. Keep ttl + grace well under that,
# or cached product pages start failing add-to-cart and search.
set beresp.ttl = 10m;
set beresp.grace = 6h;
}
}
sub vcl_deliver {
if (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
} else {
set resp.http.X-Cache = "MISS";
}
}
- Bypass by URL and by cookie. The URL rule covers the private pages and endpoints. The cookie rule covers everything a shopper with a cart or a login sees afterwards, which is what keeps mini carts and customer-group prices right.
- Strip cookies only for anonymous visitors. Once the session cookies have been ruled out, the remaining cookies are analytics and consent cookies, so all anonymous visitors can share one cache entry.
- Never store
Set-Cookie. The rule is redundant with Varnish's built-in logic, on purpose: it documents the one line nobody should remove. - Short TTL, long grace. Ten minutes fresh, six hours stale-while-revalidate during an origin outage. The total stays under the 12–24 hour lifetime of the WordPress nonces embedded in the page.
- Purge on change. The
PURGEmethod lets a WordPress cache plugin evict a product page when its price or stock changes. Without it, a ten-minute TTL is the longest a price can be wrong.
Varnish only helps cached pages. For the uncached ones, a persistent object cache keeps WordPress options, product data and query results in Redis instead of re-reading them from MySQL on every request. That's the layer that speeds up carts and checkouts. Two Redis settings matter: give it a memory cap, and let it evict the least recently used keys when full, rather than refusing writes:
maxmemory 512mb
maxmemory-policy allkeys-lru
Connect WordPress to it with a drop-in plugin such as Redis Object Cache, then check the hit ratio in its dashboard after a day of traffic. WooCommerce's docs also say that a database or object cache should not cache _wc_session_ entries; the common Redis plugins handle sessions correctly, but check any cache layer that offers database query caching.
4. Prove it: a cart isolation load test
Cache bugs don't show up when you test logged in as an admin, because admins bypass the cache. This script simulates many anonymous shoppers at once, each with its own cookie jar. Each adds one product, reads its cart through the Store API and loads the cart and checkout pages. It fails if any shopper sees a product it didn't add, or if a private page or the Store API came from a cache. It also reports response times per step, so the same run doubles as a load test of your uncached capacity.
#!/usr/bin/env python3
"""Load-test a WooCommerce store's cart and checkout through its cache, and check that
no shopper ever sees another shopper's cart. Python 3.9+, standard library only.
python3 cart_isolation_test.py https://staging.example.com 101 102 103 104 --shoppers 40
Each virtual shopper has its own cookie jar, adds one simple product (the IDs cycle)
with ?add-to-cart=, reads its cart through the Store API, then loads /cart/ and
/checkout/. It never places an order, but it does create real sessions: run it on
staging, or on production in a quiet hour with products that are safe to add.
"""
import argparse
import concurrent.futures
import http.cookiejar
import json
import statistics
import sys
import time
import urllib.request
PRIVATE = ('/cart/', '/checkout/') # pages that must never come from a cache
def from_cache(headers):
"""True if a common cache header says the response was served from a cache."""
h = {k.lower(): v.lower() for k, v in headers.items()}
if any('hit' in h.get(k, '') for k in ('x-cache', 'cf-cache-status', 'x-cache-status',
'x-litespeed-cache', 'x-proxy-cache')):
return True
return h.get('age', '0').isdigit() and int(h['age']) > 0
def shopper(base, pid, timeout):
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
opener.addheaders = [('User-Agent', 'cart-isolation-test/1.0')]
times, problems = {}, []
def get(step, path):
t = time.time()
with opener.open(base + path, timeout=timeout) as r:
body = r.read()
times[step] = time.time() - t
return r.headers, body
try:
get('add to cart', '/?add-to-cart=%d' % pid)
headers, body = get('cart API', '/wp-json/wc/store/v1/cart')
items = {i['id']: i['quantity'] for i in json.loads(body)['items']}
if items != {pid: 1}:
problems.append('LEAK: cart holds %s, this shopper added only product %d' % (items, pid))
if from_cache(headers):
problems.append('Store API cart came from cache')
for path in PRIVATE:
headers, _ = get(path.strip('/') + ' page', path)
if from_cache(headers):
problems.append('%s served from cache' % path)
except (OSError, ValueError, KeyError) as e:
problems.append('error: %s' % e)
return times, problems
def main():
ap = argparse.ArgumentParser()
ap.add_argument('base', help='store URL, e.g. https://staging.example.com')
ap.add_argument('products', type=int, nargs='+', help='IDs of simple, in-stock products')
ap.add_argument('--shoppers', type=int, default=20, help='concurrent shoppers per round')
ap.add_argument('--rounds', type=int, default=3)
ap.add_argument('--timeout', type=float, default=30)
a = ap.parse_args()
base = a.base.rstrip('/')
steps, problems, n = {}, {}, 0
for rnd in range(a.rounds):
with concurrent.futures.ThreadPoolExecutor(a.shoppers) as pool:
jobs = [pool.submit(shopper, base, a.products[(rnd * a.shoppers + i) % len(a.products)],
a.timeout) for i in range(a.shoppers)]
for job in jobs:
times, found = job.result()
n += 1
for step, sec in times.items():
steps.setdefault(step, []).append(sec)
for p in found:
problems.setdefault(p.split(':')[0], []).append(p) # group by kind
print('%d shoppers, %d rounds of %d at once\n' % (n, a.rounds, a.shoppers))
print('%-15s %6s %8s %8s %8s' % ('step', 'ok', 'p50 ms', 'p95 ms', 'max ms'))
for step, secs in steps.items():
secs.sort()
p95 = secs[min(len(secs) - 1, int(len(secs) * 0.95))]
print('%-15s %6d %8.0f %8.0f %8.0f' % (step, len(secs), statistics.median(secs) * 1000,
p95 * 1000, secs[-1] * 1000))
if not problems:
print('\nno leaks: every shopper saw only its own cart, and no private page came from cache')
return
print()
for kind, found in problems.items():
print('%4d x %s e.g. %s' % (len(found), kind, found[0]))
sys.exit(1)
if __name__ == '__main__':
main()
We ran it against a mock WooCommerce origin behind two caching proxies: one following the rules above, one that excludes /cart/ and /checkout/ by URL but caches everything else, including Set-Cookie. That second setup is the misconfiguration we'd expect most often, because the cart page itself looks fine when you check it by hand.
| Cache setup | Shoppers | Saw another shopper's cart | Store API served from cache | Exit code |
|---|---|---|---|---|
| Rules from section 3 | 60 (3 rounds of 20) | 0 | 0 | 0 |
| Excludes /cart/ and /checkout/ by URL only | 60 (3 rounds of 20) | 37 | 43 | 1 |
The failing run's report looked like this:
43 x Store API cart came from cache e.g. Store API cart came from cache
37 x LEAK e.g. LEAK: cart holds {104: 1}, this shopper added only product 107
- Use real product IDs of simple, in-stock products. Variable products need variation IDs, and out-of-stock products won't add.
- Use more shoppers than product IDs. A leak shows up when two shoppers add the same product after it was cached, so the IDs cycle on purpose.
- Raise
--shoppersstep by step for the load test. Watch the p95 of the cart API and checkout steps; when it climbs steeply, you've found your PHP worker limit. The mock's timings mean nothing, so the response times from our run aren't shown. - Run it after every change to cache rules, CDN settings, the theme or a plugin that sets cookies. Each can reopen the leak.
- It never submits an order. To test payment end to end, use a test gateway on staging, or the checkout probe from our monitoring guide on a schedule.
5. Managed: WP Engine and Cloudways
A managed host takes over the cache rules, the object cache and their upkeep. The two below solve it differently: WP Engine sells a store-focused plan where WooCommerce-aware caching is the product, while Cloudways gives you a server with WooCommerce presets and leaves sizing to you.
| Self-managed VPS | |||
|---|---|---|---|
| Cart-aware page cache | EverCache for WooCommerce with "Live Cart-aware caching" on eCommerce plans | Varnish with preset WooCommerce rules when the app is created as "WordPress with WooCommerce" | Your VCL, your responsibility |
| Object cache | Offered at platform level; confirm which object cache your plan includes | Redis included; Object Cache Pro free on servers with 4 GB of RAM or more | Install and size Redis yourself |
| Who edits the rules | WP Engine; custom exclusions through its tools or support | You, per application, under Varnish Settings (URL and cookie exclusions) | You |
| Scaling for a sale | Pick a plan by visits; Business eCommerce covers up to 600,000 a month | Resize the server, or Autonomous plans that autoscale from $99 a month | Resize or add servers yourself |
| Starting price | Essential eCommerce from $150 a month (one store, up to 100,000 visits) | $11 a month for a small DigitalOcean server; 8 GB / 4 vCPU from $88 | About $48–63 a month for an 8 GB DigitalOcean Droplet, plus your time |
| Fits | One high-revenue store, a team without a server person | Agencies and stores that want CPU per dollar and some control | Teams with an engineer who owns the stack and on-call |
Prices from WP Engine's eCommerce pricing page, Cloudways' pricing page and DigitalOcean's Droplet pricing, October 2026. Cloudways also offers a newer Lightning Stack built on Nginx and PHP-FPM without Apache; Cloudways positions it as performing well without full-page caching. Check which stack your server runs before writing Varnish exclusions for it.
6. Our opinion: who should run the cache
- The price difference is smaller than one bad incident. A cart leak exposes customers' names and addresses. If nobody on the team can read the VCL above and explain every line, pay a host to own it.
- Choose WP Engine when the store is the business and the team is marketers and merchandisers. Cart-aware caching, an object cache and support that knows WooCommerce come together, at a per-store price.
- Choose Cloudways when you want more CPU per dollar, run several stores, or want to see and change the cache rules. Create the app as WooCommerce so the presets apply, and size the server for uncached traffic.
- Self-manage only with someone on call. The VCL is the easy part; purging, updates and the 2 a.m. incident are not.
- On any of them, one page cache only. A host-level cache plus a page caching plugin plus a CDN "cache everything" rule means three sets of exclusions to keep in sync. Most leaks we'd expect come from the layer someone forgot.
- Speed up checkout, don't cache it. Fewer plugins on checkout, an object cache, a faster PHP version and payment scripts loaded only where needed help more than any cache rule.
7. Tools
Top picks
| Tool | Role | Why it fits |
|---|---|---|
| Managed WooCommerce hosting with cart-aware caching | eCommerce plans include EverCache for WooCommerce, built to cache around live carts, with search and speed tools. From $150 a month for one store up to 100,000 visits. The pick when nobody on the team should be editing cache rules. | |
| Managed cloud servers with Varnish and Redis presets | WooCommerce apps get preset Varnish exclusions, Redis is included, and Object Cache Pro is free on 4 GB+ servers. From $11 a month, with a 3-day free trial. The pick for CPU per dollar and control over exclusions. |
Alternatives: free and open-source tools
We don't earn anything from these links.
- Varnish Cache: the open-source HTTP cache the VCL in section 3 is written for.
- Redis Object Cache: free plugin that connects WordPress's object cache to Redis.
- WooCommerce caching documentation: the official list of pages and cookies to exclude.
For a wider comparison of managed WordPress hosts, see ScalaHosting vs Cloudways vs Kinsta.
8. Pitfalls
- Testing while logged in. Admins bypass the cache, so the bug never appears for the person checking. Test in a private window, or with the script.
- Excluding the cart page but not the Store API. Block-based carts and mini carts fetch
/wp-json/wc/store/v1/cart. A rule written for the classic cart page misses it. - A CDN rule that caches HTML. A "cache everything" rule at the CDN sits in front of all of the above. It needs the same cookie bypass, or it undoes the origin's rules.
- Geolocation and currency. If prices or taxes depend on the visitor's country, the cache must vary by it. WooCommerce's "Geolocate (with page caching support)" option adds a parameter to the URL for this; a currency switcher plugin needs its own rule.
- Customer-group and B2B prices. Logged-in wholesale buyers must bypass the page cache entirely; the cookie rule in the VCL does this, a URL-only rule doesn't.
- No purge on price changes. An import that updates 5,000 prices leaves cached pages wrong until their TTL runs out. Purge, or keep the TTL short.
- Turning off cart fragments without checking the theme. Since WooCommerce 7.8 the cart fragments script loads only where a cart widget is shown. Themes that hard-code a header mini cart still load it, and disabling it there leaves the mini cart empty.
Cache anonymous product and category pages for minutes, bypass everything for shoppers with a cart or a login, never cache the cart, checkout, Store API or any response that sets a cookie, and put Redis under the uncached requests. Size the server for the traffic that can't be cached, because that's the traffic that buys. If no one on the team owns the cache rules, WP Engine's cart-aware caching or Cloudways' WooCommerce presets cost less than one leaked cart. Whoever runs it, run the cart isolation test after every change.