David Lewin David Lewin
SEO, Traffic & Automation

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.

Short answer

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.

How this guide was made

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

QuestionAnswer
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 costOne entry per variation on tested pages; untested pages stay one entry
Carts, checkout, logged-in usersNot cached, but still bucketed, so the variation doesn't change after add-to-cart
ConversionsSent from the Worker when the order-received page is first viewed, with order total and ID
PlanConvert'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.

Client-side testing vs a decision at the edge
Client-side testing vs a decision at the edgeClient-side tool / Edge decision → Testing script / Cache → Browser / BrowserClient-side tool: the cache serves the original HTML to everyoneClient-side toolthe cache serves the originalHTML to everyoneEdge decision: a Worker reads the visitor's cookie and picks a variationEdge decisiona Worker reads the visitor'scookie and picks a variationTesting script: downloads, then decides; an anti-flicker snippet hides the page meanwhileTesting scriptdownloads, then decides; ananti-flicker snippet hidesthe page meanwhileCache: keyed on the URL with ?_ab= added: one entry per variationCachekeyed on the URL with ?_ab=added: one entry pervariationBrowser: repaints with the variation, or shows a blank page until the script finishesBrowserrepaints with the variation,or shows a blank page untilthe script finishesBrowser: receives finished HTML for one variation, nothing to swapBrowserreceives finished HTML forone variation, nothing toswap
On the left, the variation is applied after the page arrives. On the right, the page that arrives is already the variation, and it can still be cached.

3. The design: the URL carries the decision

  1. The Worker identifies the visitor by a convert_visitor_id cookie, creating one if needed.
  2. 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.
  3. The Worker rewrites the origin request to /product/mug/?_ab=pdp-hero.free-shipping. It also deletes any _ab the browser sent, so nobody can pick a variation or poison the cache.
  4. 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.
  5. WordPress renders one variation. The plugin reads _ab and 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) and wc-ajax pass 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

CheckResult
Convert's config CDN returns 500: page 200 with the original, nothing tracked; next request after recovery is bucketedPass
New visitor: exactly one variation in the HTML; no testing script and no hidden bodyPass
Visitor cookie: one year, HttpOnly, Secure, SameSite=Lax; variation page sent as private, max-age=0Pass
Same visitor, 5 more views: same variation; exposure event sent from the edge with experience and variation IDsPass
?_ab= from the browser ignored: the visitor keeps their own variationPass
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 entryPass
Add-to-cart form POST on the product page: same variation in the responsePass
Visitor with a cart: cache bypassed, same variationPass
Store API JSON passes through untouchedPass
Checkout through the Worker; order-received page: conversion with amount, item count and order ID, header strippedPass
Worker restarted before the order: conversion still carries the variation, no second exposure eventPass
Order-received page reloaded: no second conversionPass
No short description: both variations' text leaks into JSON-LD and the excerpt; with one: no leakPass (documents the leak and the fix)
Product page response time (median, local test)
Product page response time (median, local test)Cached variation through the Worker: 42 ms; WordPress rendering the variation: 668 msCached variation through the Worker: 42 msCached variation through the Worker42 msWordPress rendering the variation: 668 msWordPress rendering the variation668 ms
First of three runs, on Wrangler's local runtime and PHP's built-in server on one machine. Across the runs: 42 to 78 ms cached, 607 ms to 2.4 s rendered. The point isn't the exact numbers: a server-side test that bypasses the cache pays the full render on every view.

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 _ab in 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

ToolRoleWhy it fits
ConvertA/B testing with a Full Stack SDKThe 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.

10. Pitfalls

  • Letting PHP pick the variation behind a page cache: the first visitor's variation is served to everyone.
  • Vary: Cookie with 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 _ab from 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.
Bottom line

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.

Tools mentioned in this guide · affiliate links

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.