Cloudflare Workers

Run Convert experiments at the Cloudflare edge with zero flicker

Run Convert A/B tests, feature flags, and personalisation at the Cloudflare edge. Visitors receive a fully modified page with zero flicker and sub-millisecond bucketing latency because all decisions happen server-side inside a Cloudflare Worker, before the response reaches the browser.

sequenceDiagram
    participant V as Visitor
    participant W as Cloudflare Worker
    participant O as Origin Server
    participant C as Convert CDN

    V->>W: GET /page
    W->>C: Fetch config (edge-cached)
    C-->>W: SDK config data
    W->>W: SDK: createContext → runExperience
    W->>O: Fetch origin page
    O-->>W: HTML response
    W->>W: HTMLRewriter: apply variation
    W-->>V: Modified page (zero flicker)
    W--)C: Send tracking event (waitUntil)

Table of Contents

  1. Why Edge Experimentation?
  2. Architecture Overview
  3. Prerequisites
  4. Setup
  5. Utility Package Reference
  6. Usage Patterns
  7. Caching Strategies
  8. Performance Considerations
  9. Troubleshooting
  10. FAQ

1. Why Edge Experimentation?

Traditional client-side A/B testing has three fundamental problems:

ProblemClient-SideCloudflare Edge
FlickerJavaScript loads, evaluates, then modifies DOM → visible flashHTML is modified before delivery → no flash
Ad-blocker susceptibilityTesting scripts blocked by ad-blockersChanges are server-side, invisible to ad-blockers
Performance overheadExtra JS download + parse + execute on every pageZero client-side overhead; Worker adds ~5ms at edge

The Convert FullStack SDK already runs in Cloudflare Workers without modification. Its HTTP client auto-detects the Workers runtime (server-with-fetch) and uses the native fetch API. What this guide provides is the Cloudflare-specific integration layer: edge-cached config, cookie management, HTMLRewriter patterns, and tracking event handling.


2. Architecture Overview

┌─────────────────────────────────────────────────────────┐
│                   Cloudflare Edge PoP                   │
│                                                         │
│  ┌────────────────────────────────────────────────────┐ │
│  │              Cloudflare Worker                     │ │
│  │                                                    │ │
│  │  1. Parse visitor cookie                           │ │
│  │  2. Fetch config (edge-cached via cf.cacheTtl)     │ │
│  │  3. Init SDK with cached config                    │ │
│  │  4. createContext → runExperience (MurmurHash)     │ │
│  │  5. Fetch origin page                              │ │
│  │  6. HTMLRewriter: apply variation                  │ │
│  │  7. Set cookie + respond                           │ │
│  │  8. waitUntil: releaseQueues (flush tracking)      │ │
│  │                                                    │ │
│  └──────────────────┬──────────────┬──────────────────┘ │
│                     │              │                    │
└─────────────────────┼──────────────┼────────────────────┘
                      │              │
       ┌──────────────▼──┐    ┌──────┴────────────────────┐
       │  Origin Server  │    │  Convert CDN              │
       │  (your site)    │    │  (config + tracking;      │
       └─────────────────┘    │   cached at edge)         │
                              └───────────────────────────┘

Key principles:

  • No KV required. Config is cached using Cloudflare's built-in cf.cacheTtl fetch option, which is free and works on all plans.
  • No persistence store required. The SDK uses deterministic MurmurHash bucketing — the same visitor ID always produces the same variation assignment. A cookie identifies the visitor; no server-side state is needed.
  • Tracking events must be flushed explicitly. The SDK's internal event queue uses setTimeout to batch and release tracking events. In Workers, the isolate may finish before that timer fires. Always call context.releaseQueues() inside ctx.waitUntil() to flush events before the Worker completes.
  • The SDK singleton persists across requests within the same Worker isolate. Config is fetched from the CDN only on cold start or when the edge cache entry expires.

3. Prerequisites

  • Cloudflare account with Workers enabled (free tier works for development)
  • Convert account with a FullStack project and at least one active experience
  • Node.js ≥ 18 and Wrangler CLI (yarn global add wrangler)
  • Your SDK key in the format ACCOUNT_ID/PROJECT_ID (found in Convert app settings)

4. Setup

4.1 Create the Worker Project

# Option A: From the demo (works out of the box with staging credentials)
cd javascript-sdk/demo/cloudflare-workers
yarn install   # from the monorepo root

# Option B: From scratch
mkdir convert-edge-worker && cd convert-edge-worker
yarn init -y
yarn add @convertcom/js-sdk @convertcom/js-sdk-cloudflare
yarn add -D wrangler @cloudflare/workers-types typescript

4.2 Configure wrangler.toml

name = "convert-edge-experiments"
main = "src/index.ts"
compatibility_date = "2024-12-01"

[vars]
CONVERT_SDK_KEY = "YOUR_ACCOUNT_ID/YOUR_PROJECT_ID"
# Origin server URL — the Worker proxies to this and modifies the response.
# For local testing with the demo's built-in origin server: "http://localhost:8888"
# For production: "https://your-site.com"
ORIGIN_URL = "https://your-site.com"

That's it — no KV namespace needed for the basic setup. The SDK config is cached automatically at the Cloudflare edge using the native fetch cache.

Important: The Worker must fetch pages from a separate origin server (via ORIGIN_URL), not from its own URL. Calling fetch(request) directly in a Worker loops back to itself. Use a helper like fetchOrigin() (see the demo source) to rewrite the request URL to the origin.

4.3 Development

The demo includes a built-in origin server that serves HTML pages for testing:

# Terminal 1: Start the origin server (serves test pages on port 8888)
cd demo/cloudflare-workers
yarn origin

# Terminal 2: Start the Cloudflare Worker (proxies to the origin on port 8787)
cd demo/cloudflare-workers
yarn dev

Visit http://localhost:8787/ to see the demo in action. The demo is pre-configured with the Convert staging project and serves pages at /, /events, /statistics, and /pricing.

# Deploy to production (requires ORIGIN_URL to be a public URL)
wrangler deploy

5. Utility Package Reference

Install: yarn add @convertcom/js-sdk-cloudflare

EdgeConfigCache

Caches Convert SDK configuration using Cloudflare's built-in cf.cacheTtl fetch option. No KV namespace required — the response is cached at the edge automatically.

import {EdgeConfigCache} from '@convertcom/js-sdk-cloudflare';

const configCache = new EdgeConfigCache(
  'ACCOUNT/PROJECT', // SDK key
  300                // cache TTL in seconds (default: 300)
);

// Serves from edge cache when available, fetches from CDN on cache miss
const configData = await configCache.getConfig();

// Force refresh (e.g. triggered by a webhook or cron)
await configCache.refreshConfig();

KVDataStore (Optional)

Bridges the SDK's synchronous DataStore interface with Cloudflare's async KV API. This is optional — the SDK uses deterministic MurmurHash bucketing, so the same visitor ID always gets the same variation. You only need KV persistence if you want to:

  • Preserve bucketing across experience config changes
  • Store custom visitor attributes between requests
  • Share state across multiple Workers or routes

Note: Cloudflare KV is a paid add-on and may not be available on all plans.

import {KVDataStore} from '@convertcom/js-sdk-cloudflare';

const dataStore = new KVDataStore(
  env.CONVERT_KV, // KV namespace binding
  'visitor'       // key prefix (default: 'visitor')
);

// Phase 1: Load from KV (async, before SDK)
await dataStore.load(visitorId);

// Phase 2: SDK uses get/set synchronously
const sdk = new ConvertSDK({data: configData, dataStore});
const context = sdk.createContext(visitorId);
// ... run experiments ...

// Phase 3: Save back to KV (async, after SDK)
await dataStore.save(visitorId, 86400); // TTL: 24 hours

Cookie Helpers

Parse and set visitor ID cookies on Workers Request/Response objects.

import {
  getVisitorId,
  setVisitorIdCookie,
  generateVisitorId
} from '@convertcom/js-sdk-cloudflare';

// Read visitor ID from request cookies
const visitorId = getVisitorId(request, 'convert_visitor_id');

// Generate a new one if missing
const newId = generateVisitorId(); // crypto.randomUUID()

// Set the cookie on the response
const headers = new Headers(response.headers);
setVisitorIdCookie(headers, newId, 'convert_visitor_id', 31536000);

Cache Helpers

Build variation-aware cache keys and set appropriate headers.

import {buildCacheKey, setCacheHeaders} from '@convertcom/js-sdk-cloudflare';

// Create a cache key that separates entries by variation
const cacheKey = buildCacheKey(request, variation.key);
// Internally: appends ?_conv_v=variation-1 to the URL

// Set Vary: Cookie and Cache-Control on the response
setCacheHeaders(headers, 300); // 5-minute edge cache

6. Usage Patterns

Pattern 1: Page-Level A/B Test (HTMLRewriter)

This is the core pattern. The Worker fetches the origin page, then uses Cloudflare's HTMLRewriter to modify elements before delivery.

import ConvertSDK from '@convertcom/js-sdk';
import {
  EdgeConfigCache,
  getVisitorId, setVisitorIdCookie, generateVisitorId,
  setCacheHeaders
} from '@convertcom/js-sdk-cloudflare';

interface Env {
  CONVERT_SDK_KEY: string;
  ORIGIN_URL: string;
}

// Rewrite the request URL to the origin server.
// Without this, fetch(request) in a Worker loops back to itself.
function fetchOrigin(request: Request, env: Env): Promise<Response> {
  const url = new URL(request.url);
  const origin = new URL(env.ORIGIN_URL);
  url.hostname = origin.hostname;
  url.port = origin.port;
  url.protocol = origin.protocol;
  return fetch(new Request(url.toString(), request));
}

let sdk: InstanceType<typeof ConvertSDK> | null = null;
let sdkReadyPromise: Promise<InstanceType<typeof ConvertSDK>> | null = null;

async function getSDK(env: Env) {
  if (sdk) return sdk;
  if (sdkReadyPromise) return sdkReadyPromise;
  sdkReadyPromise = (async () => {
    const config = await new EdgeConfigCache(env.CONVERT_SDK_KEY).getConfig();
    sdk = new ConvertSDK({data: config, network: {tracking: true}});
    await sdk.onReady();
    return sdk;
  })();
  return sdkReadyPromise;
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
    const convert = await getSDK(env);

    // Identify visitor
    let visitorId = getVisitorId(request) || generateVisitorId();

    // Run experiment (deterministic bucketing — no persistence needed)
    const context = convert.createContext(visitorId);
    const variation = context.runExperience('homepage-hero-test', {
      locationProperties: {location: 'homepage'}
    });

    // Fetch origin (IMPORTANT: use fetchOrigin, not fetch(request))
    const originResponse = await fetchOrigin(request, env);

    // Apply variation via HTMLRewriter
    let response = originResponse;
    if (variation && typeof variation !== 'string' && variation.key === 'bold-headline') {
      response = new HTMLRewriter()
        .on('h1.hero', {
          element(el) { el.setInnerContent('Ship Faster, Test Smarter'); }
        })
        .on('.hero-subtitle', {
          element(el) { el.setInnerContent('Zero-flicker A/B testing at the edge.'); }
        })
        .transform(originResponse);
    }

    // Response headers
    const headers = new Headers(response.headers);
    setVisitorIdCookie(headers, visitorId);
    setCacheHeaders(headers);

    // IMPORTANT: Flush tracking events before the Worker finishes.
    // The SDK queues events internally with a setTimeout-based timer.
    // In Workers that timer may never fire, so you must release manually.
    ctx.waitUntil(context.releaseQueues('edge'));

    return new Response(response.body, {status: response.status, headers});
  }
};

Pattern 2: Asset / Image Swap

Replace images, stylesheets, or scripts per variation:

if (variation.key === 'modern-assets') {
  response = new HTMLRewriter()
    .on('link[rel="stylesheet"][href*="main.css"]', {
      element(el) {
        el.setAttribute('href', '/css/main-v2.css');
      }
    })
    .on('img.product-hero', {
      element(el) {
        const src = el.getAttribute('src') || '';
        el.setAttribute('src', src.replace('/images/', '/images/v2/'));
      }
    })
    .on('script[src*="analytics.js"]', {
      element(el) {
        el.setAttribute('src', '/js/analytics-v2.js');
      }
    })
    .transform(originResponse);
}

Pattern 3: Split URL Redirect

Serve a completely different origin page without the visitor seeing a redirect:

if (variation.key === 'new-checkout') {
  const originUrl = new URL(env.ORIGIN_URL);
  const url = new URL(request.url);
  url.hostname = originUrl.hostname;
  url.port = originUrl.port;
  url.protocol = originUrl.protocol;
  url.pathname = '/checkout-v2' + url.pathname.replace('/checkout', '');
  // Fetch the alternate page transparently (URL in browser doesn't change)
  return fetch(new Request(url.toString(), request));
}

Pattern 4: SPA Injection

For single-page applications, inject bucketing decisions as a global JS variable so the client-side framework can apply them:

const variations = context.runExperiences({
  locationProperties: {location: 'homepage'}
});

const decisions = JSON.stringify(
  variations.filter(v => v && typeof v !== 'string')
);

response = new HTMLRewriter()
  .on('head', {
    element(el) {
      el.append(
        `<script>window.__CONVERT_DECISIONS__=${decisions};</script>`,
        {html: true}
      );
    }
  })
  .transform(originResponse);

Then in your SPA (React, Vue, etc.):

// Read decisions injected at the edge
const decisions = window.__CONVERT_DECISIONS__ || [];
const heroVariation = decisions.find(d => d.experienceKey === 'homepage-hero-test');
if (heroVariation?.key === 'bold-headline') {
  // Apply variation in your component
}

Pattern 5: Feature Flags at the Edge

Use feature flags to gate entire page sections:

const feature = context.runFeature('new-pricing-table', {
  locationProperties: {location: 'pricing'}
});

if (feature && typeof feature !== 'string' && feature.status === 'enabled') {
  response = new HTMLRewriter()
    .on('#pricing-table', {
      element(el) {
        el.replace('<div id="pricing-table"><!-- new pricing HTML --></div>', {html: true});
      }
    })
    .transform(originResponse);
}

7. Caching Strategies

SDK Config Caching

The EdgeConfigCache class uses Cloudflare's native cf.cacheTtl fetch option to cache the SDK config response at the edge. This is:

  • Free — no KV reads/writes, works on all Cloudflare plans
  • Automatic — Cloudflare manages the cache lifecycle
  • Fast — cached responses are served from the same PoP, no network hop to KV
// Under the hood, EdgeConfigCache does:
const response = await fetch(configUrl, {
  cf: { cacheTtl: 300, cacheEverything: true }
});

To force a refresh (e.g. after updating your Convert project config):

await configCache.refreshConfig(); // fetches with cacheTtl: 0

Edge Cache Per Variation

Cache origin responses per variation to avoid redundant origin fetches:

import {buildCacheKey} from '@convertcom/js-sdk-cloudflare';

async function fetchWithEdgeCache(
  request: Request,
  variationKey: string,
  ctx: ExecutionContext
): Promise<Response> {
  const cache = caches.default;
  const cacheKey = buildCacheKey(request, variationKey);

  // Try edge cache first
  let response = await cache.match(cacheKey);
  if (response) return response;

  // Cache miss: fetch from origin
  response = await fetchOrigin(request, env);
  const cloned = new Response(response.body, response);
  cloned.headers.set('Cache-Control', 'public, max-age=300');

  // Store in edge cache (non-blocking)
  ctx.waitUntil(cache.put(cacheKey, cloned.clone()));

  return cloned;
}

Recommended Cache-Control Headers

Resource TypeHeaderReason
A/B tested HTML pagespublic, max-age=300 + Vary: CookieShort cache, respects visitor bucketing
Static assets (CSS/JS/images)public, max-age=31536000, immutableLong cache, fingerprinted filenames
SDK config (edge cache)cf.cacheTtl: 300Matches SDK's default 5-minute refresh

When to Bypass Cache

// Don't cache personalised or authenticated pages
if (request.headers.get('Authorization') || url.pathname.startsWith('/account/')) {
  return fetchOrigin(request, env);
}

8. Performance Considerations

What This Adds

OperationLatencyWhen
Config fetch (edge-cached)~1-5 msEvery request (Cloudflare edge cache hit)
Config fetch (CDN, cache miss)~50-100 msCold start or cache expiry
SDK bucketing (MurmurHash)< 0.1 msEvery request (CPU)
HTMLRewriter transform~1-3 msEvery request (streaming)
Tracking POST~50-100 msBackground (waitUntil)
Total added to response~5-8 ms

SDK Behaviour in Workers

Two SDK patterns behave differently in Workers than in long-lived servers:

  1. Config refresh timer (setTimeout in core.ts) — In a long-lived Node.js server, this refreshes config every 5 minutes. In Workers, the isolate may not persist that long. Solution: Pass config data directly via EdgeConfigCache and let the edge cache TTL handle refresh. The setTimeout is harmless (it just won't fire).

  2. Event batching (ApiManager.enqueue()) — The SDK queues tracking events and releases them after a setTimeout delay (default: 1-10 seconds). In Workers, the isolate finishes before that timer fires, so events would be lost. Solution: Always call context.releaseQueues() inside ctx.waitUntil() at the end of each request. This immediately flushes all pending events without blocking the response:

// This is REQUIRED — without it, tracking events will be lost
ctx.waitUntil(context.releaseQueues('edge-request-complete'));

Cloudflare Worker Limits

LimitFree PlanPaid Plan
Script size1 MB10 MB
CPU time per request10 ms30 ms (50 ms burst)
Subrequests per request5050

The Convert SDK at 24 KB gzipped (136 KB uncompressed) fits well within script size limits.


9. Troubleshooting

Visitor Gets Different Variations Across Requests

Cause: Visitor cookie not being set or read correctly.

Check:

# Verify the cookie is set
curl -v https://your-site.com/page 2>&1 | grep -i 'set-cookie.*convert'

# Verify the cookie is sent back
curl -v -b "convert_visitor_id=test-123" https://your-site.com/page

Fix: Ensure setVisitorIdCookie() is called and the response headers are forwarded. Check that the cookie domain matches your site.

SDK Returns Null Context

Cause: Config data is empty or invalid.

Check:

# Verify config fetch
curl https://cdn-4.convertexperiments.com/api/v1/config/YOUR_ACCOUNT_ID/YOUR_PROJECT_ID

Fix: Verify your SDK key. Check that the experience is active (not paused/draft) in the Convert dashboard.

HTMLRewriter Changes Not Appearing

Cause: Selectors don't match the origin HTML, or the response is not HTML.

Check:

  • View the origin HTML source and verify your CSS selectors match
  • Add console.log in the Worker and check with wrangler tail
  • Verify the Content-Type is text/html

Experience Returns a RuleError

Cause: The visitor doesn't match the experience's audience/location rules.

Check: The locationProperties and visitorProperties passed to runExperience() must match the rules configured in Convert.

const variation = context.runExperience('my-test', {
  locationProperties: {location: 'events'}  // must match location rules configured in Convert
});
console.log('Result:', variation); // Will show the RuleError string if rules don't match

Note: Location matching uses custom properties (e.g. {location: 'events'}), not URL paths. Check your Convert project's location rules to see what properties are expected.

Tracking Events Not Appearing in Convert Dashboard

Cause: releaseQueues() not called before the Worker finishes.

Fix: Always call context.releaseQueues() inside ctx.waitUntil():

// This flushes the SDK's internal event queue immediately
ctx.waitUntil(context.releaseQueues('edge-request-complete'));

Without this, the SDK's internal setTimeout-based release timer will never fire because the Worker isolate finishes first.

Edge Cache Serving Stale Config

Fix: Call configCache.refreshConfig() programmatically (e.g. via a cron trigger or webhook endpoint). This fetches with cacheTtl: 0 which bypasses the edge cache.


10. FAQ

Can I use this with any website, or only Cloudflare-hosted sites?

Any website that has Cloudflare as its DNS/proxy provider (orange cloud enabled). The origin server can be hosted anywhere (AWS, Vercel, your own server). Cloudflare Workers intercept requests at the edge regardless of where the origin sits.

How does this compare to client-side Convert tracking scripts?

They serve different purposes. The client-side tracking script is a drop-in solution for marketing teams. Edge experimentation via Workers is for developers who want full control, zero flicker, and server-side experimentation on their own infrastructure.

Do I need Cloudflare KV?

No. The basic setup requires no KV at all:

  • Config caching uses Cloudflare's native fetch cache (cf.cacheTtl) — free on all plans
  • Visitor bucketing is deterministic (MurmurHash) — the same visitor ID always gets the same variation, no server-side state needed
  • Visitor identity is stored in a cookie

KV is only needed if you want to persist bucketing decisions across experience config changes, or store custom visitor attributes. See the KVDataStore section.

Does the SDK work in Cloudflare Pages Functions?

Yes. Pages Functions are Cloudflare Workers under the hood. The same patterns apply. Place your function in functions/[[path]].ts to intercept all routes.

Can I run multiple experiments on the same page?

Yes. Use context.runExperiences() to get all variation decisions at once, then chain multiple HTMLRewriter handlers:

const variations = context.runExperiences({
  locationProperties: {location: 'homepage'}
});

let rewriter = new HTMLRewriter();
for (const v of variations) {
  if (v && typeof v !== 'string') {
    // Add handlers per experiment
    if (v.experienceKey === 'hero-test' && v.key === 'v1') {
      rewriter = rewriter.on('h1', {element(el) { el.setInnerContent('New Hero'); }});
    }
    if (v.experienceKey === 'cta-test' && v.key === 'v1') {
      rewriter = rewriter.on('.cta', {element(el) { el.setInnerContent('Buy Now'); }});
    }
  }
}
response = rewriter.transform(originResponse);

How do I track conversions?

Call context.trackConversion() in the Worker (for server-side events like form submissions) or from the client-side SDK (for click events):

// Server-side (in Worker)
if (request.method === 'POST' && url.pathname === '/api/purchase') {
  context.trackConversion('purchase-goal', {
    conversionData: [{key: 'amount', value: 49.99}]
  });
  ctx.waitUntil(context.releaseQueues('conversion'));
}

What happens if the Worker throws an error?

The demo wraps everything in a try/catch that falls back to fetchOrigin(request, env) — the unmodified origin page. Visitors always get a working page; experiments degrade gracefully.

Why must I call releaseQueues() manually?

The SDK internally batches tracking events and releases them after a setTimeout delay. This works in long-lived Node.js servers but not in Cloudflare Workers where the isolate finishes before the timer fires. releaseQueues() immediately flushes all pending events. Wrapping it in ctx.waitUntil() ensures the tracking POST completes without blocking the response to the visitor.


Additional Resources