Server-side A/B testing in WooCommerce: no flicker, and the page cache still works
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.
Client-side testing tools flicker because the cached page arrives first and a script rewrites it afterwards, or hide the page until the script runs. To avoid both, pick the variation before the HTML exists. A Cloudflare Worker running Convert's JavaScript SDK reads the visitor's cookie, buckets them with Convert's deterministic hash, and adds the decision to the origin URL as ?_ab=experience.variation. A small WordPress plugin then renders only that variation's block. Because the decision is in the URL, every cache in between keeps one entry per variation instead of one per visitor. In our test on a real WooCommerce store, 200 new visitors split 110/90 and each variation was rendered by WordPress once; every other request was a cache hit. A cached variation came back in a median 42 to 78 ms across three runs, against 0.6 to 2.4 seconds for an origin render. Conversions are sent from the order-received page, once per order. Convert is our pick; its Full Stack SDK is on the Pro plan.
You install an A/B testing tool, launch a test on your product page, and customers start seeing the old headline for a split second before the new one appears. Or the whole page stays blank until the testing script loads. On a WooCommerce store behind a page cache it's worse: the cache serves the same HTML to everyone, so the variation can only be applied in the browser, after the page has already been delivered. This guide moves the decision to the edge. A Cloudflare Worker picks the variation before the request reaches WordPress, WordPress renders only that variation, and the cache keeps one copy of each variation instead of none.
We read Convert's developer documentation for the Full Stack SDK and its Cloudflare Workers guide, the source of @convertcom/js-sdk 5.0.2 and @convertcom/js-sdk-cloudflare 2.0.1, Convert's public demo project configuration and its pricing page (all October 2026). The Worker ran in Wrangler 4.151.3's local runtime (workerd) with the real Convert SDK, in front of a real WordPress 7.1.3 and WooCommerce 11.2.1 store. Convert's configuration CDN and tracking endpoint were a local mock built from the format of Convert's public demo config. All 26 checks pass. We didn't run it on Cloudflare's network or against a live Convert account, so the timings are from one machine.
1. The short answer
| Question | Answer |
|---|---|
| Why does client-side testing flicker? | The cached original arrives first; a script swaps the content after the browser has started painting |
| Where should the decision happen? | Before the HTML is built or served: in a Cloudflare Worker, here with Convert's SDK |
| How does WordPress know the variation? | The Worker adds ?_ab=experience.variation to the origin URL; a small plugin renders only that variation's block |
Why the URL, not a header or Vary: Cookie? | Caches key on the URL. A per-visitor cookie in Vary means one cache entry per visitor, so no hits at all |
| Cache cost | One entry per variation on tested pages; untested pages stay one entry |
| Carts, checkout, logged-in users | Not cached, but still bucketed, so the variation doesn't change after add-to-cart |
| Conversions | Sent from the Worker when the order-received page is first viewed, with order total and ID |
| Plan | Convert's Full Stack and feature flags are listed on its Pro plan |
2. Why client-side tests flicker, and why caching makes it worse
A visual A/B testing tool works in the browser. The page loads, the tool's script downloads, works out which variation the visitor is in, and edits the page. Between the first paint and the edit, the visitor sees the original: that's the flicker. Vendors hide it with an "anti-flicker" snippet that makes the page invisible until the script finishes, or until a timeout, which turns flicker into a blank page and a slower Largest Contentful Paint.
Google Optimize used the same model, and when Google shut it down on 30 September 2023, many stores moved to another script-based tool with the same trade-off. A server-side test avoids it, but on WooCommerce it runs into the page cache. Whether it's Cloudflare, Varnish, a host's Nginx cache or a WordPress caching plugin, the cache stores one HTML response per URL. If PHP picks a variation, the first visitor's variation is cached and served to everyone. If PHP isn't involved (a cache hit), it can't pick anything.
So the variation has to be chosen in front of the cache, and the cache has to be told about it.
3. The design: the URL carries the decision
- The Worker identifies the visitor by a
convert_visitor_idcookie, creating one if needed. - Convert's SDK decides. It runs the experiences whose location rules match the path (here, paths starting with
/product/). Bucketing is a MurmurHash of the visitor ID, so the same visitor always lands in the same variation, with no lookups. - The Worker rewrites the origin request to
/product/mug/?_ab=pdp-hero.free-shipping. It also deletes any_abthe browser sent, so nobody can pick a variation or poison the cache. - The cache key is that URL. The Worker uses Cloudflare's Cache API with the rewritten URL, so a tested page has one entry per variation and an untested page has one entry.
- WordPress renders one variation. The plugin reads
_aband drops the blocks of the other variations before the HTML is sent.
Why not a header? Cloudflare's cache key is the URL unless you build a custom key, and a header-only decision is easy to lose: a "cache everything" rule or a Varnish default hash between the Worker and WordPress would store the first variation under the plain URL. Why not Vary: Cookie? Every visitor has a different cookie, so every visitor gets a separate cache entry and the hit rate drops to zero. Convert's Cloudflare helper setCacheHeaders() sets Cache-Control: public with Vary: Cookie; we don't use it, and send variation pages to the browser as private, max-age=0 instead.
4. The Worker
Install the SDK and Convert's Cloudflare helpers (npm i @convertcom/js-sdk @convertcom/js-sdk-cloudflare), save this as src/index.js, and deploy it on a route that covers your store, for example example.com/*. Run it on your own domain: Cloudflare's Cache API doesn't store anything on workers.dev.
// Cloudflare Worker in front of WooCommerce: Convert decides the variation at the edge, the origin renders only that
// variation, and each variation gets its own cache entry. No client-side testing script, so nothing to flicker.
import ConvertSDK from '@convertcom/js-sdk';
import { EdgeConfigCache, getVisitorId, setVisitorIdCookie, generateVisitorId } from '@convertcom/js-sdk-cloudflare';
const TTL = 300; // seconds: edge cache for anonymous HTML, and how long an isolate keeps one Convert config
const SESSION = /(?:^|;\s*)(wordpress_logged_in_|wp_woocommerce_session_|woocommerce_items_in_cart|woocommerce_cart_hash|comment_author_|wp-postpass_)/;
const PASS = /^\/(wp-admin|wp-json|wp-login\.php|wp-content|wp-includes|xmlrpc\.php)|\.\w{2,5}$/;
let sdk = null, sdkAt = 0, loading = null;
function getSDK(env) {
if (sdk && Date.now() - sdkAt < TTL * 1000) return sdk;
loading ||= new EdgeConfigCache(env.CONVERT_SDK_KEY, TTL, env.CONVERT_CONFIG_ENDPOINT || undefined).getConfig()
.then(async (data) => {
const s = new ConvertSDK({ data, environment: env.CONVERT_ENV, network: { tracking: true },
...(env.CONVERT_TRACK_ENDPOINT && { api: { endpoint: { track: env.CONVERT_TRACK_ENDPOINT } } }) });
await s.onReady();
[sdk, sdkAt] = [s, Date.now()];
return s;
})
.finally(() => { loading = null; }); // a failed config fetch is retried on the next request, not kept for the isolate's life
return sdk || loading; // an expired config keeps serving while the new one loads
}
function toOrigin(url, env) {
if (!env.ORIGIN_URL) return url.toString(); // on a Worker route, a fetch to the same hostname goes to the origin
const o = new URL(env.ORIGIN_URL);
return Object.assign(new URL(url), { protocol: o.protocol, host: o.host }).toString();
}
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
url.searchParams.delete('_ab'); // only the Worker chooses variations
const isPage = (request.headers.get('Accept') || '').includes('text/html');
if (!isPage || PASS.test(url.pathname) || url.searchParams.has('wc-ajax')) {
return fetch(toOrigin(new URL(request.url), env), request);
}
const cookies = request.headers.get('Cookie') || '';
let visitorId = getVisitorId(request);
const isNew = !visitorId;
visitorId ||= generateVisitorId();
const seen = new Map((/(?:^|;\s*)ab_seen=([^;]*)/.exec(cookies)?.[1] || '').split(',').filter(Boolean).map((p) => p.split('.')));
let context = null, ab = [];
try {
context = (await getSDK(env)).createContext(visitorId);
const decisions = context.runExperiences({ locationProperties: { path: url.pathname } });
ab = (Array.isArray(decisions) ? decisions : []).filter((v) => v && typeof v === 'object').map((v) => [v.experienceKey, v.key]);
} catch (e) {
console.error('Convert unavailable, serving the original:', e.message); // no decision = control, never an error page
}
if (ab.length) url.searchParams.set('_ab', ab.map((p) => p.join('.')).sort().join(','));
// One cache entry per URL + variation combination. Pages without a running test have no _ab and stay one entry.
const cacheable = request.method === 'GET' && !SESSION.test(cookies);
const key = new Request(url.toString());
let res = cacheable ? await caches.default.match(key) : null;
const status = res ? 'HIT' : cacheable ? 'MISS' : 'BYPASS';
if (!res) {
res = await fetch(toOrigin(url, env), { method: request.method, headers: request.headers, body: request.body, redirect: 'manual' });
if (cacheable && res.status === 200 && !res.headers.has('Set-Cookie') && !/no-store|no-cache|private/i.test(res.headers.get('Cache-Control') || '')) {
const copy = new Response(res.clone().body, res);
copy.headers.set('Cache-Control', `s-maxage=${TTL}`);
ctx.waitUntil(caches.default.put(key, copy));
}
}
res = new Response(res.body, res);
res.headers.set('X-Edge-Cache', status);
if (ab.length || status === 'HIT') res.headers.set('Cache-Control', 'private, max-age=0'); // never let a shared cache store a variation under the plain URL
if (isNew) setVisitorIdCookie(res.headers, visitorId);
for (const [e, v] of ab) seen.set(e, v);
if (ab.length) res.headers.append('Set-Cookie', `ab_seen=${[...seen].map((p) => p.join('.')).join(',')}; Path=/; Max-Age=7776000; SameSite=Lax; Secure; HttpOnly`);
// A first view of an order-received page: the origin plugin says so in a header that we strip
const conversion = res.headers.get('X-AB-Conversion');
if (conversion) {
res.headers.delete('X-AB-Conversion');
if (context && seen.size) {
const [orderId, amount, items] = conversion.split(' ');
// A fresh isolate has no memory of this visitor: re-derive their variations (same hash, same result) without a second exposure event
for (const e of seen.keys()) context.runExperience(e, { ignoreLocationProperties: true, enableTracking: false });
context.trackConversion(env.CONVERT_GOAL, { conversionData: [
{ key: 'amount', value: Number(amount) }, { key: 'productsCount', value: Number(items) }, { key: 'transactionId', value: orderId }] });
}
}
if (context) ctx.waitUntil(context.releaseQueues('edge')); // the SDK's own timer never fires in a Worker
return res;
},
};
And wrangler.toml:
name = "edge-ab"
main = "src/index.js"
compatibility_date = "2026-09-01"
routes = [{ pattern = "example.com/*", zone_name = "example.com" }]
[vars]
CONVERT_SDK_KEY = "ACCOUNT_ID/PROJECT_ID"
CONVERT_ENV = "production"
CONVERT_GOAL = "purchase" # the key of a revenue goal in your Convert project
Notes on the choices:
- A failed config load isn't kept. Convert's own Cloudflare demo keeps the first SDK promise for the life of the isolate. If that first config fetch fails, every later request in that isolate gets the same failure until Cloudflare recycles it. Here the promise is cleared, so the next request tries again. In the test the config CDN returned 500 at startup: the page was served as the original, and the next request after recovery was bucketed.
- The config is refreshed every 5 minutes. An isolate can live for a long time, and an experience you pause in Convert should stop at the edge soon after.
- No decision means the original, never an error page.
- Every request releases Convert's queue in
waitUntil. The SDK batches events with a timer that doesn't fire in a Worker; Convert's guide warns that an early return before the flush drops the exposure event. - Only pages are bucketed. Assets,
/wp-admin,/wp-json(the Store API) andwc-ajaxpass straight through.
5. The WordPress plugin
Drop this into wp-content/mu-plugins/. In the block editor, put each variation in its own block (a Group, Cover or pattern) and give it the extra CSS class ab-{experience key}--{variation key}, for example ab-pdp-hero--control and ab-pdp-hero--free-shipping. The keys are the ones in your Convert project. Put the original first: without a decision, the first variation on the page is shown.
<?php
/**
* Plugin Name: Edge A/B variants
* Description: Server-side A/B variations for cached WooCommerce pages. The edge (a Cloudflare Worker running Convert's SDK) picks the variation and adds it to the origin URL as _ab=experience.variation; this plugin drops the blocks of every other variation before the HTML is sent. The URL, not a header, carries the choice, so any cache in between keys each variation separately.
*
* In the block editor, give each variation's block (Group, Cover, a pattern...) the extra CSS class ab-{experience key}--{variation key}.
* Put the original first: with no decision (edge down, page outside the test) the first variation in the page is the one shown.
*/
defined( 'ABSPATH' ) || exit;
function ab_choices() {
static $c = null;
if ( null === $c ) {
$c = array();
foreach ( explode( ',', sanitize_text_field( wp_unslash( $_GET['_ab'] ?? '' ) ) ) as $pair ) {
$p = explode( '.', $pair, 2 );
if ( 2 === count( $p ) ) {
$c[ $p[0] ] = $p[1];
}
}
}
return $c;
}
add_filter( 'render_block', function ( $html, $block ) {
static $first = array();
if ( ! preg_match( '/(?:^|\s)ab-([\w-]+?)--([\w-]+)/', $block['attrs']['className'] ?? '', $m ) ) {
return $html;
}
[ , $exp, $var ] = $m;
$first[ $exp ] ??= $var;
return ( ab_choices()[ $exp ] ?? $first[ $exp ] ) === $var ? $html : '';
}, 10, 2 );
// Conversion signal for the Worker, once per order: "order_id total item_count". The Worker strips it.
add_action( 'template_redirect', function () {
if ( ! is_wc_endpoint_url( 'order-received' ) ) {
return;
}
$order = wc_get_order( absint( get_query_var( 'order-received' ) ) );
if ( ! $order || ! hash_equals( $order->get_order_key(), wc_clean( wp_unslash( $_GET['key'] ?? '' ) ) )
|| $order->has_status( 'failed' ) || $order->get_meta( '_ab_converted' ) ) {
return;
}
$order->update_meta_data( '_ab_converted', time() );
$order->save();
header( sprintf( 'X-AB-Conversion: %d %s %d', $order->get_id(), $order->get_total(), $order->get_item_count() ) );
} );
One thing the test caught: WooCommerce builds the product's JSON-LD description and the auto-generated excerpt from the raw product description, not from rendered blocks. With variations in the description and no short description, both variations' text went to every visitor, in the structured data and in the excerpt block. Giving tested products a short description fixed it: WooCommerce then uses that instead, and the other variation's text appeared nowhere in the page.
6. Conversions without a browser script
The checkout happens on your origin, so the origin knows when an order is placed. On the first view of an order-received page, with a valid order key, the plugin adds an X-AB-Conversion header with the order ID, total and item count, and marks the order so a reload doesn't repeat it. The Worker strips the header and calls Convert's trackConversion().
The conversion needs the visitor's variation attached, and a Worker isolate that never saw this visitor doesn't have it: the SDK keeps bucketing data in memory. The Worker keeps an ab_seen cookie of the experiences a visitor was bucketed into, and before converting it re-runs those experiences with ignoreLocationProperties: true and enableTracking: false. That recomputes the same variation from the same hash without sending a second exposure event. We tested it by restarting the Worker between the product view and the order. With the re-run, the conversion carried the variation. Without it, it reached Convert with only the goal ID.
7. Test results
| Check | Result |
|---|---|
| Convert's config CDN returns 500: page 200 with the original, nothing tracked; next request after recovery is bucketed | Pass |
| New visitor: exactly one variation in the HTML; no testing script and no hidden body | Pass |
Visitor cookie: one year, HttpOnly, Secure, SameSite=Lax; variation page sent as private, max-age=0 | Pass |
| Same visitor, 5 more views: same variation; exposure event sent from the edge with experience and variation IDs | Pass |
?_ab= from the browser ignored: the visitor keeps their own variation | Pass |
| 200 new visitors: 90 control, 110 variation; each variation rendered once, everything else a cache hit (199 hits, 1 miss) | Pass |
/shop/ (not in the test): no _ab, one shared cache entry | Pass |
| Add-to-cart form POST on the product page: same variation in the response | Pass |
| Visitor with a cart: cache bypassed, same variation | Pass |
| Store API JSON passes through untouched | Pass |
| Checkout through the Worker; order-received page: conversion with amount, item count and order ID, header stripped | Pass |
| Worker restarted before the order: conversion still carries the variation, no second exposure event | Pass |
| Order-received page reloaded: no second conversion | Pass |
| No short description: both variations' text leaks into JSON-LD and the excerpt; with one: no leak | Pass (documents the leak and the fix) |
Not tested: Cloudflare's production network (where the Cache API is per data center, so each location fills its own cache), Varnish, a live Convert account and its reports, classic themes, and the CPU time per request on Cloudflare's free plan (Convert's guide lists a 10 ms limit there; check your Worker's metrics).
8. Our opinion: trade-offs
- Use this for tests that change what the page says or how it's laid out: headlines, trust badges, shipping messages, product page sections. That's where client-side flicker is most visible.
- Client-side tools are still fine for small cosmetic tests below the fold, and they let marketers build variations without a developer. The edge approach needs every variation to exist as a block on the page.
- Each test doubles the cache entries for the pages it covers, and two overlapping tests on the same page make four. Keep tests on the pages they're about; Convert's location rules do that.
- Cloudflare APO and other full-page caches in front of the Worker need checking: if anything caches HTML before the Worker runs, the decision comes too late.
- Varnish setups can use the same plugin. Anything that puts
_abin the backend URL works, since Varnish hashes the URL by default. You'd need your own bucketing there, because Convert's SDK runs in JavaScript.
Related: caching WooCommerce without leaking carts and A/B testing a headless Next.js store.
9. Tools
Top pick
| Tool | Role | Why it fits |
|---|---|---|
| A/B testing with a Full Stack SDK | The same Convert project runs visual tests and server-side experiences, and its JavaScript SDK runs unmodified in Cloudflare Workers, with a helper package for cookies, config caching and cache keys. Bucketing is deterministic, so the edge needs no database. Full Stack and feature flags are listed on the Pro plan ($599 a month, or $5,040 a year paid annually, for 100K tested users a month); Growth starts at $299 a month paid annually. 15-day free trial without a card (pricing page, October 2026). |
Alternatives: free tools and references
We don't earn anything from these links.
- GrowthBook: open-source feature flags and experimentation with SDKs, if you'd rather host the experiment platform yourself.
- Cloudflare Workers Cache API: how
caches.defaultbehaves, including its per-data-center scope. - Convert: Cloudflare Workers guide: the vendor's own patterns, including HTMLRewriter variations.
10. Pitfalls
- Letting PHP pick the variation behind a page cache: the first visitor's variation is served to everyone.
Vary: Cookiewith a visitor cookie: one cache entry per visitor, so no cache.- Passing the variation only in a header when any URL-keyed cache sits between the edge and WordPress.
- Accepting
_abfrom the browser: visitors pick variations and can fill the cache with them. - Skipping bucketing for visitors with a cart: the variation changes right after add-to-cart.
- Variations in the product description without a short description: the other variation leaks into JSON-LD and excerpts.
- Converting in a fresh isolate without restoring the variation: the conversion arrives without it.
- Returning early without releasing Convert's queue in
waitUntil: lost exposure events and a skewed sample.
Decide at the edge, put the decision in the URL, and let WordPress render only the chosen block. The page arrives finished, so there's nothing to flicker, and the cache keeps working with one entry per variation. Keep carts in their variation, send conversions from the order-received page, and give tested products a short description so the other variation doesn't leak.