Back to blog
Engineering7 min read

How We Built Zero-Flicker A/B Testing at the Edge

M

Michał Pogoda-Rosikoń

Founder · 2026-03-20

How We Built Zero-Flicker A/B Testing at the Edge

Every A/B testing tool has the same problem

You know the flicker. The page loads, you see the original headline for 200ms, then it snaps to the variant. It's ugly, it tanks your Core Web Vitals, and -- worst of all -- it biases your test data because users notice the swap.

When I started building SplitMonk, eliminating flicker was the first engineering problem I tackled. Our first approach was terrible. Our second approach was OK. The third one actually worked. Here's the whole story.

Attempt 1: Hide everything until JS loads (terrible)

The standard industry approach is an "anti-flicker snippet" that hides the page body until the testing script loads and applies variants:

<style>.async-hide { opacity: 0 !important }</style>
<script>document.documentElement.classList.add('async-hide')</script>

Then your A/B testing script removes the class after applying changes. The problem? If the script takes 300ms to load (common on mobile), your users stare at a blank white page for 300ms. Google penalizes this in CLS and LCP scores. We measured it: this approach added 180-400ms to LCP depending on network conditions.

Worse, if the script fails to load entirely (ad blockers, network errors), the page stays hidden forever. You need a timeout fallback, which means you're right back to flicker -- just delayed.

Attempt 2: Race the DOM with inline JS (OK-ish)

We tried injecting the variant-application logic as an inline <script> in the <head>, so it runs before the browser paints anything:

<script>
  // Runs synchronously before first paint
  const variant = getVariantFromCookie();
  if (variant === 'B') {
    document.addEventListener('DOMContentLoaded', () => {
      document.querySelector('h1').textContent = 'New headline';
    });
  }
</script>

This is faster, but it still has a fundamental timing problem. The browser can paint the <h1> before DOMContentLoaded fires. On a fast connection with cached assets, you won't see flicker. On a 3G connection loading a 200KB HTML page? Flicker city.

Attempt 3: Edge-side injection with Cloudflare Workers (the fix)

The real solution is obvious in hindsight: modify the HTML before it reaches the browser. If the variant is already in the response when the browser starts parsing, there's nothing to flicker because the original content never exists in the DOM.

Here's how our CDN worker handles it:

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // 1. Get the original page from the origin
    const response = await fetch(request);

    // 2. Look up active experiments for this URL
    const experiments = await getExperiments(env, request.url);
    if (!experiments.length) return response;

    // 3. Assign user to variants (cookie-based, deterministic hash)
    const assignments = assignVariants(request, experiments);

    // 4. Apply all variant modifications via HTMLRewriter
    let rewriter = new HTMLRewriter();
    for (const { experiment, variant } of assignments) {
      for (const mod of variant.modifications) {
        rewriter = rewriter.on(mod.selector, {
          element(el) {
            el.setInnerContent(mod.content, { html: mod.isHtml });
          }
        });
      }
    }

    // 5. Inject tracking snippet and set assignment cookie
    rewriter = rewriter.on('head', {
      element(el) {
        el.append(buildTrackingSnippet(assignments), { html: true });
      }
    });

    const modifiedResponse = rewriter.transform(response);
    // Set variant assignment cookies on response
    return addAssignmentCookies(modifiedResponse, assignments);
  }
};

The key insight: HTMLRewriter is a streaming parser. It doesn't buffer the entire HTML document -- it transforms bytes as they flow through the Worker. This adds roughly 0.3ms to the response time. Not 300ms. Not 30ms. Zero-point-three milliseconds.

The Framer/React hydration problem (and MutationObserver)

Edge-side HTML modification works perfectly for static sites. But many of our customers use React, Next.js, or Framer. These frameworks hydrate the page after initial render -- meaning React can overwrite our edge modifications with the original content when it takes over the DOM.

Our first attempt to solve this was injecting the modifications again after hydration. But hydration timing is unpredictable -- it could happen 50ms after load or 500ms.

The fix: MutationObserver. We inject a tiny inline script (412 bytes gzipped) that watches for React's hydration and re-applies modifications:

<script>
(function(){
  var mods = __SM_MODS__; // injected by the edge worker
  var applied = false;
  function apply() {
    mods.forEach(function(m) {
      var el = document.querySelector(m.s);
      if (el && el.textContent !== m.c) {
        el.textContent = m.c;
        applied = true;
      }
    });
  }
  apply(); // initial application
  var obs = new MutationObserver(function() {
    apply(); // re-apply if React hydration overwrites our changes
  });
  obs.observe(document.body, { childList: true, subtree: true });
  // Stop observing after 5 seconds to avoid performance overhead
  setTimeout(function() { obs.disconnect(); }, 5000);
})();
</script>

The anti-flicker CSS is even simpler. We add a <style> tag that hides only the specific elements being tested, not the entire page:

<style>.sm-af{opacity:0!important}</style>

The edge worker adds class="sm-af" to tested elements, and the MutationObserver removes the class after applying modifications. If the script somehow fails, only the tested elements are hidden -- not the entire page. Graceful degradation.

Performance numbers

I benchmarked SplitMonk against three popular client-side testing tools across 1,000 page loads on a Moto G4 (mid-range Android):

| Metric | SplitMonk (edge) | Tool A (client) | Tool B (client) | Tool C (client) | |--------|-------------------|------------------|------------------|------------------| | Added CLS | 0.000 | 0.08 | 0.12 | 0.05 | | Added LCP | +0.3ms | +280ms | +410ms | +150ms | | Added TTFB | +1.2ms | 0ms | 0ms | 0ms | | Script size | 412B (inline) | 48KB | 72KB | 35KB |

Client-side tools don't affect TTFB (they run after the page loads), but they destroy CLS and LCP -- the metrics Google actually uses for search ranking.

If your A/B testing tool adds 0.08 CLS to every page, it's costing you search traffic. Google's "good" threshold for CLS is 0.1. That means your testing tool alone consumes 80% of your budget before the page even does anything.

Caching: the part nobody talks about

Edge testing interacts with CDN caching in a way that's easy to get wrong. If Cloudflare caches a page and serves the same cached HTML to everyone, all users see the same variant. That's not an A/B test -- that's a deployment.

We solve this by setting Vary: Cookie on cached responses and including the variant assignment in the cache key. Each variant combination gets its own cached entry. For a test with 2 variants, you get 2 cached copies. For 3 variants across 2 experiments, you get 6. The cache hit rate drops slightly, but the correctness gain is worth it.

The result

Moving A/B testing to the edge eliminated an entire class of bugs: no flicker, no layout shift, no 50KB JavaScript bundle, no script-loading race conditions, no "the page was blank for 400ms" support tickets. Our snippet adds 0.3ms and 412 bytes. Your Core Web Vitals stay clean, and your test data reflects genuine user preference instead of "which variant loaded faster."

This is what I mean when I say most A/B testing tools get the architecture wrong. They solve a server-side problem with client-side code. The edge is the right layer for this.

Michał Pogoda-Rosikoń

Michał Pogoda-Rosikoń

Founder

Founder of SplitMonk and bards.ai. Data scientist from Wroclaw University of Technology, specializing in NLP and machine learning. Building AI-powered tools that optimize conversions on autopilot.