David Lewin David Lewin
Cloud VPS & Hosting

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.

Short answer

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.

How this guide was made

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.

How a cached Set-Cookie header merges two shoppers' carts
How a cached Set-Cookie header merges two shoppers' cartsShopper A adds a mug / Cache stores the response → Shopper B adds the same mug / B receives A's session cookie → ResultShopper A adds a mug: GET /?add-to-cart=101, the origin creates a session and sends Set-Cookie: wp_woocommerce_session_…Shopper A adds a mugGET /?add-to-cart=101, theorigin creates a session andsends Set-Cookie:wp_woocommerce_session_…Cache stores the response: including the Set-Cookie header, because someone unset the "don't cache cookies" rule to raise the hit rateCache stores the responseincluding the Set-Cookieheader, because someone unsetthe "don't cache cookies"rule to raise the hit rateShopper B adds the same mug: same URL, so the cache answers: HITShopper B adds the samemugsame URL, so the cacheanswers: HITB receives A's session cookie: from now on WooCommerce sees one shopper, not twoB receives A's sessioncookiefrom now on WooCommerce seesone shopper, not twoResult: B's cart shows A's mug; at checkout, B can see the name and address A typed in. The origin logged nothing unusual.ResultB's cart shows A's mug; at checkout, B can see the name andaddress A typed in. The origin logged nothing unusual.
Our test reproduced this with a proxy that caches everything except /cart/ and /checkout/: 37 of 60 simulated shoppers saw a cart that wasn't theirs (section 4).
What gets cached by mistakeWhat shoppers seeHow 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 formSevere: personal data exposure, wrong orders
The cart, checkout or My Account pageAnother shopper's items, totals or account linksSevere
The Store API (/wp-json/wc/store/v1/cart) or wc-ajax callsBlock-based cart and mini cart show someone else's contentsSevere, and easy to miss: the page itself is excluded, the API isn't
Product pages for shoppers who already have a cartMini cart shows 0 items; member or customer-group prices show retail pricesConversion loss, B2B pricing errors
Product pages cached longer than about 12 hoursAdd to cart or search fails, because the WordPress nonce in the page has expiredIntermittent, hard to reproduce
Pages varied by country or currency, cached onceWrong currency or tax displayPricing errors in other markets

2. The rule: what to cache, what to bypass

RequestPage cacheWhy
Product, category and content pages for anonymous visitorsCache, about 10 minutes, purge on price or stock changeThis is the traffic a cache exists for
Any request carrying a cart, session or login cookieBypassMini cart, prices and stock messages depend on the session
/cart/, /checkout/, /my-account/BypassWooCommerce's docs list them as pages that must stay dynamic
?add-to-cart=, wc-ajax=, /wp-json/, /wc-api/, admin-ajax.phpBypassActions and APIs, not pages
Any response with Set-CookieNever storeThe next visitor would receive the cookie
Images, CSS, JSCache long, at the CDNSame 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.

PHP workers busy at 1,000 concurrent shoppers, by share of shoppers with a cart
PHP workers busy at 1,000 concurrent shoppers, by share of shoppers with a cart5% have a cart (a normal day): 1.25 workers; 20% have a cart (a promotion): 5 workers; 40% have a cart (a flash sale): 10 workers; 40%, with a slow 1.5 s checkout: 30 workersOur estimate5% have a cart (a normal day): 1.25 workers5% have a cart (a normal day)1.25 workers20% have a cart (a promotion): 5 workers20% have a cart (a promotion)5 workers40% have a cart (a flash sale): 10 workers40% have a cart (a flash sale)10 workers40%, with a slow 1.5 s checkout: 30 workers40%, with a slow 1.5 s checkout30 workers
Assumptions: each shopper loads a page every 20 seconds (50 pages per second in total), only shoppers with a cart bypass the cache, and an uncached WooCommerce request takes 0.5 seconds of PHP time. Store API and AJAX calls come on top. Measure your own uncached response time; it's the number that moves the result most.

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 PURGE method 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 setupShoppersSaw another shopper's cartStore API served from cacheExit code
Rules from section 360 (3 rounds of 20)000
Excludes /cart/ and /checkout/ by URL only60 (3 rounds of 20)37431

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 --shoppers step 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.

WP EngineCloudwaysSelf-managed VPS
Cart-aware page cacheEverCache for WooCommerce with "Live Cart-aware caching" on eCommerce plansVarnish with preset WooCommerce rules when the app is created as "WordPress with WooCommerce"Your VCL, your responsibility
Object cacheOffered at platform level; confirm which object cache your plan includesRedis included; Object Cache Pro free on servers with 4 GB of RAM or moreInstall and size Redis yourself
Who edits the rulesWP Engine; custom exclusions through its tools or supportYou, per application, under Varnish Settings (URL and cookie exclusions)You
Scaling for a salePick a plan by visits; Business eCommerce covers up to 600,000 a monthResize the server, or Autonomous plans that autoscale from $99 a monthResize or add servers yourself
Starting priceEssential 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 $88About $48–63 a month for an 8 GB DigitalOcean Droplet, plus your time
FitsOne high-revenue store, a team without a server personAgencies and stores that want CPU per dollar and some controlTeams 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.

Monthly price for a store that needs about 8 GB of RAM
Monthly price for a store that needs about 8 GB of RAMSelf-managed DigitalOcean Droplet, 8 GB: 48 $; Cloudways on DigitalOcean, 8 GB / 4 vCPU: 88 $; Cloudways Autonomous Growth (autoscaling): 99 $; WP Engine Essential eCommerce: 150 $List pricePlus your timeSelf-managed DigitalOcean Droplet, 8 GB: 48 $Self-managed DigitalOcean Droplet, 8 GB$48Cloudways on DigitalOcean, 8 GB / 4 vCPU: 88 $Cloudways on DigitalOcean, 8 GB / 4 vCPU$88Cloudways Autonomous Growth (autoscaling): 99 $Cloudways Autonomous Growth (autoscaling)$99WP Engine Essential eCommerce: 150 $WP Engine Essential eCommerce$150
List prices, October 2026; the plans are not identical in resources. The self-managed bar excludes the hours spent on VCL, updates, backups and incidents, which is the real cost of that row.

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

ToolRoleWhy it fits
WP EngineManaged WooCommerce hosting with cart-aware cachingeCommerce 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.
CloudwaysManaged cloud servers with Varnish and Redis presetsWooCommerce 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.

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.
Bottom line

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.

David Lewin
David Lewin

Founder of StealthScale.io and e-commerce infrastructure engineer writing field-tested playbooks on proxies, cloud VPS, anti-detect browsers and SEO.