The problem with caching a multi-currency store
Edge caching is the single biggest speed win available to a WooCommerce store. It is also the fastest way to show a customer in London a page priced in dirhams.
The reason is simple. A CDN caches one copy of each URL. Our store renders the same URL differently depending on the shopper's currency, which WCML resolves from a wcml_client_currency cookie. Cache that page once and the first visitor's currency becomes everyone's currency, until the entry expires. No error, no warning, no failed order. Just quietly wrong prices.
How we found out the hard way
In March we shipped a Worker that cached HTML at the edge but did not include currency in the cache key. Some visitors saw values from one currency rendered against another currency's symbol.
The debugging took far longer than the fix, and the detour is the useful part:
- We first assumed a WCML bug, because some products converted and others did not.
- We then assumed Cloudflare APO. It was not enabled.
- We tried client-side rate conversion, computing prices manually in PHP, fetching prices over AJAX, and sending no-cache headers from a mu-plugin.
- None of it worked, because the Worker was intercepting every request before any of those fixes could run.
The Worker was invisible in every place we were looking. We found it by listing the zone's Worker routes directly:
Removing the route and purging fixed the prices immediately. Two things we now treat as rules:
- Check for Workers before anything else when a caching problem does not make sense. A Worker sits in front of every other layer you are inspecting.
cf-cache-status: DYNAMICdoes not mean the page is uncached. Workers use their owncaches.defaultstore, and it reports as DYNAMIC. We spent hours trusting that header.
The fix: currency belongs in the cache key
Every cache entry is now namespaced by the currency it was rendered in. The same product page is stored separately per currency:
With 15 active currencies over roughly 100 cacheable URLs, that is about 1,500 entries per edge location. That sounds like a lot and is completely trivial for Cloudflare. Cache fragmentation is not the constraint people assume it is.
The harder half: the visitor with no cookie
A first-time visitor has no currency cookie, so there is nothing to build a key from. Our first version simply bypassed the cache for those requests, which meant every new visitor, the exact audience you most want to impress, got the slowest possible page: 400-600ms from a distant region.
The second version infers the currency from request.cf.country using a country-to-currency map that mirrors the store's live WCML settings. That inferred value does three jobs at once:
- It becomes the cache key, so the response can be cached on a first visit.
- It is injected as a cookie into the origin request, so WCML renders the same currency the Worker is about to cache under. Without this the key and the content can disagree, which is the original bug wearing a different hat.
- It is set on the response, so the visitor's next request carries a real cookie.
First-visit TTFB went from 400-600ms to 10-30ms. The map has to be maintained against the store's actual currency settings, not a generic country list: several countries we ship to have no active currency of their own and correctly fall back to EUR or USD.
Set-Cookie is the trap
WCML refreshes the currency cookie on every response. If you cache a response with its Set-Cookie header intact, you are caching one visitor's cookie and handing it to everyone who hits that entry afterwards.
The Worker does three separate things with cookies, and all three matter:
- Checks for session, login and cart cookies, and bypasses the cache entirely when it finds them.
- Strips
Set-Cookiefrom the copy it stores. - Preserves
Set-Cookieon the response it sends to the visitor.
What never gets cached
Speed is not worth a broken checkout. Requests pass straight through when any of these are true:
- The method is not GET or HEAD.
- The path is
/cart,/checkout,/my-account,/wp-adminor/wp-json. - The request carries
wc-ajax,add-to-cartor a preview parameter. - A
wordpress_logged_in_cookie is present. - The cart is non-empty, or a WooCommerce session cookie exists.
- The response is not a 200 HTML document, or it carries
no-storeorprivate.
Tracking and campaign parameters are stripped from the key, so a link with UTM tags still hits the same cache entry as the clean URL rather than creating a duplicate.
The results
| Metric | Page cache only | With edge Worker |
|---|---|---|
| TTFB, nearby region | ~180ms | 10-30ms |
| TTFB, distant region | 400-600ms | 10-30ms |
| Origin load | Every request | Cache misses only |
The distant-region row is the one that matters commercially. Most of our customers are nowhere near our origin server, and they were paying half a second before the first byte arrived.
One gotcha that only shows up later
New content is a cold cache in every currency except the first one visited. A freshly published page gets a page-cache file for whichever currency loaded it first. Every other currency misses, falls through to PHP, and WooCommerce returns Cache-Control: private, no-store on that uncached response. The Worker sees no-store and correctly refuses to cache it. The result is that most of your audience hits raw PHP on every request until a cache file exists for their currency.
The fix is to warm every currency yourself after publishing, rather than waiting for real visitors to do it:
Note the -4. Our warming box resolves outbound over IPv6, and the firewall rule allowing it was written for an IPv4 address, so without forcing IPv4 every warm request returned 403 and silently warmed nothing.
What we would tell you not to do
- Do not patch cached prices with client-side JavaScript. It is fragile and invites double conversion.
- Do not bypass the platform's price rendering to multiply rates yourself.
- Do not add an AJAX call to fetch fresh prices. That defeats the entire point of caching the page.
- Do not reach for cache-control headers from a plugin before you have confirmed what is actually intercepting the request.
- Do not clear all transients to fix a pricing display. It breaks unrelated things and does not address the cause.
The short version
Edge caching a multi-currency store is not risky because caching is hard. It is risky because the cache key is an assertion about what makes two requests equivalent, and currency is invisible in the URL. Put it in the key, decide what to do about visitors who have not told you their currency yet, and be careful about which headers you store.