Skip to content
One Bit
← All writing

Engineering

Caching a Multi-Currency Store at the Edge Without Serving the Wrong Prices

TL;DR: We cache HTML at Cloudflare's edge for a store that sells in 26 currencies across 40+ countries. TTFB went from ~180ms on a nearby PoP and 400-600ms on a distant one to 10-30ms everywhere. Getting there cost us an incident where visitors were served the wrong currency, because the cache key did not include the currency. This is the whole design, including the parts that bit us.

31 August 2026 · 5 min read · Khaled Absi

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:

# The first thing to check when a cache behaves impossibly GET /zones/{zone_id}/workers/routes

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: DYNAMIC does not mean the page is uncached. Workers use their own caches.default store, 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:

// One entry per currency, not one entry per URL /product/keffiyeh/?_cf_currency=AED /product/keffiyeh/?_cf_currency=USD /product/keffiyeh/?_cf_currency=GBP

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-Cookie from the copy it stores.
  • Preserves Set-Cookie on 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-admin or /wp-json.
  • The request carries wc-ajax, add-to-cart or 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-store or private.

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

MetricPage cache onlyWith edge Worker
TTFB, nearby region~180ms10-30ms
TTFB, distant region400-600ms10-30ms
Origin loadEvery requestCache 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:

# Warm every currency, desktop and mobile, after publishing for CUR in AED SAR KWD QAR GBP EUR USD CAD AUD SGD MYR IDR PKR TRY ZAR JOD OMR BHD; do curl -4 -s -o /dev/null -b "wcml_client_currency=$CUR" -A "$DESKTOP_UA" "$POST_URL" curl -4 -s -o /dev/null -b "wcml_client_currency=$CUR" -A "$MOBILE_UA" "$POST_URL" done

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.