A/B testing at the origin feels natural until you watch what it does to your cache. Every experiment you run with server-side branching or client-side injection either fragments your cache keys into unusable slivers or forces a round trip back to origin for every single visitor. After running edge experiments for several large e-commerce and media properties, I've learned that the CDN edge is the right place to make bucketing decisions — not because it's trendy, but because it's the only layer that can assign variants, serve the correct bytes, and keep the origin completely out of the hot path. This article shows how to implement that architecture without trading one performance problem for another.
Why Origin-Coupled Experiments Erode CDN Hit Ratios
In my experience, most teams start with the simplest A/B setup: the origin reads a cookie or a query parameter, decides if the user is A or B, and renders a different template. From a product perspective it works. From a delivery perspective it quietly destroys the value of your CDN. If the origin decides the variant, you have two bad options. You can set Vary: Cookie and watch your cache hit ratio drop from 96% to somewhere in the 40s as every unique cookie value creates a new cache entry, or you can bypass cache entirely for experiment traffic with Cache-Control: private, no-store and send 100% of that traffic back to origin.
Neither is sustainable when 30-40% of your traffic is in an experiment at any given time, which is common for mature optimization programs. I audited a retail site last year that was running three concurrent tests via origin logic. Their edge hit ratio for product detail pages was 58% during the test window. After we moved bucketing to the edge, it recovered to 94% without changing the experiment definitions at all. The variants were still there; we just stopped making the origin responsible for deciding who saw what.
The cache fragmentation problem behind Vary
The HTTP Vary mechanism is precise and unforgiving. As defined by the HTTP Working Group Specifications, Vary: Cookie tells a cache that the response depends on the entire Cookie request header. That includes session IDs, analytics IDs, consent strings — everything. You might think you are varying only on x-experiment-id, but the cache sees the whole header value. Two users in the same variant with different session cookies are treated as two different cache keys. This is why generic Vary usage is so dangerous for experiments. You need a cache key that is normalized to just the experiment assignment, not the raw cookie jar.
The latency tax you pay twice
Origin-based branching also adds latency in two places. First, the request must travel to origin for the decision. Second, the variant payload is often assembled client-side after the HTML arrives, which introduces flicker and an extra render pass. I've measured 80-120ms of added TTFB for cache-bypassed experiment traffic on otherwise edge-cacheable pages, plus another 150-300ms of client-side variant application. When you move that logic to the edge PoP that is 15-30ms away from the user, both of those costs disappear. The assignment happens before the cache lookup, and the correct variant is served as static, cacheable bytes.
Designing Deterministic Bucketing at the Edge Without External Lookups
The core of any edge experiment system is a deterministic bucketing function that runs entirely inside the edge runtime. No fetches to a feature flag service, no KV lookup in the hot path if you can avoid it. I've found that the most reliable pattern is to hash a stable user identifier and map the hash space to percentage buckets. This gives you sticky assignment, even distribution, and zero dependency on origin state.
For authenticated traffic, use the user ID. For anonymous traffic, use a first-party experiment ID you set yourself. Do not use IP address — it is not stable for mobile users and it creates privacy issues you don't need. The identifier should be read from a cookie you control, and if absent, you generate it at the edge and set it on the response.
// Deterministic bucketing function for edge runtime
// Runs in < 1ms, no I/O, no external fetch
function getVariant(userId, experimentId, trafficAllocation) {
// FNV-1a hash - fast and deterministic at the edge
function hashString(str) {
let hash = 2166136261;
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
}
return hash >>> 0;
}
const hash = hashString(userId + ':' + experimentId);
const bucket = hash % 100;
let cumulative = 0;
for (const variant of trafficAllocation) {
cumulative += variant.percentage;
if (bucket < cumulative) return variant.name;
}
return 'control';
}
// Example allocation: 50/50 test
const variant = getVariant(
request.headers.get('cookie')?.match(/exp_id=([^;]+)/)?.[1] || 'anonymous',
'pdp_checkout_2024_11',
[{ name: 'control', percentage: 50 }, { name: 'treatment', percentage: 50 }]
);
Cookie persistence and sticky assignment across sessions
Once a user is bucketed, you must persist that decision. I set two cookies at the edge: a long-lived anonymous ID (exp_id) with a 1-year Max-Age and a per-experiment variant cookie (exp_pdp_checkout_2024_11=treatment) with a 30-90 day expiry that matches the experiment duration. The variant cookie is what the edge checks on subsequent requests; the anonymous ID is what makes the hash deterministic if the variant cookie is missing. Always set Path=/; SameSite=Lax; Secure and scope the Domain correctly. In my experience, forgetting SameSite is the most common reason assignments leak or reset on cross-site navigations.
Consistent hashing for stable allocation when you add variants
A simple modulo 100 works for a fixed 50/50 split, but if you later expand to 33/33/33 or add a holdout, naive re-bucketing will flip users between variants. For long-running programs, use a hash ring or a consistent range map where each variant owns specific intervals. That way adding a new variant only reassigns users from the interval you carve out for it. If you are already using edge functions, this logic pairs well with the patterns described in Edge Functions Deep Dive: Cloudflare Workers, Deno Deploy and Vercel Edge where you can keep the bucketing and assignment in a single worker without cold starts.
Serving Variant Responses Through Edge Rewrites and Surrogate Key Isolation
Assigning the variant is only half the job. You have to serve the correct bytes without issuing a redirect. Redirects are poison for experiments — they add a round trip, they flash the wrong URL, and they break if a bot or prefetch follows them. The pattern I use now is an internal rewrite at the edge: the user requests /products/blue-widget, the edge decides they are in treatment, and internally fetches /products/blue-widget?__variant=treatment or from a different origin path or object store key, then returns that response as if it came directly from the requested URL. The browser never sees the rewrite.
// Cloudflare Workers example: internal rewrite with cache key isolation
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// Don't experiment on bots, static assets, or prefetch
if (isBot(request.headers.get('user-agent')) || url.pathname.match(/\.(js|css|png|webp|woff2)$/)) {
return fetch(request);
}
const userId = getOrCreateExpId(request);
const variant = getVariant(userId, 'pdp_checkout_2024_11', [
{ name: 'control', percentage: 50 },
{ name: 'treatment', percentage: 50 }
]);
// Isolate cache by variant but hide it from the browser
const variantRequest = new Request(request);
// Use a custom cache key header that your CDN respects
variantRequest.headers.set('x-edge-variant', variant);
// Optional: rewrite to variant-specific origin path
// url.searchParams.set('__variant', variant);
let response = await fetch(variantRequest, {
cf: {
cacheKey: url.hostname + url.pathname + '#variant=' + variant,
cacheTtl: 3600
}
});
response = new Response(response.body, response);
// Persist assignment
response.headers.append('Set-Cookie', `exp_id=${userId}; Path=/; Max-Age=31536000; SameSite=Lax; Secure`);
response.headers.append('Set-Cookie', `exp_pdp_checkout_2024_11=${variant}; Path=/; Max-Age=2592000; SameSite=Lax; Secure`);
// Tell downstream caches this varies by assignment, not by raw Cookie
response.headers.set('Vary', 'x-edge-variant');
response.headers.set('x-experiment', 'pdp_checkout_2024_11=' + variant);
return response;
}
}
function isBot(ua) {
return /googlebot|bingbot|facebookexternalhit|slackbot/i.test(ua || '');
}
URL rewrites versus 302 redirects
A rewrite keeps the URL clean and maintains a single entry in your analytics. A redirect creates two pageviews and confuses attribution. I've seen teams try to use 302 Found with Cache-Control: private for experiments and then wonder why their SEO traffic drops — search engines will follow the redirect and index the variant URL if you don't handle canonical tags perfectly. With a rewrite, the canonical URL stays /products/blue-widget for both variants and you control indexing with a single tag. Pair this with Geolocation Routing at the Edge: Country-Level Logic Without Redirects if you are also personalizing by region; the same rewrite principle applies there.
Cache key design for variant isolation
Your cache key must include the variant but nothing else that causes fragmentation. I recommend either a custom surrogate key (x-edge-variant) or a normalized cache key that concatenates the path with the assignment. On Fastly you would use Vary: x-edge-variant with a custom VCL hash. On Cloudflare you can use the cacheKey option or Cache-Tag. On Akamai, use Edge-Cache-Tag and include the experiment ID in the tag. The key point is to avoid Vary: Cookie entirely. Purge by surrogate key when the experiment ends: purge tag pdp_checkout_2024_11 removes both variant caches atomically without affecting the rest of the site.
| Implementation Pattern | Cache Efficiency | Origin Overhead | Visual Stability (CLS/Flicker) |
|---|---|---|---|
| Client-side (e.g., Optimizely Web) | High — one cache entry | None | Poor — flicker until JS executes, 150-400ms |
| Origin branching with Vary: Cookie | Very Low — fragmented by cookie cardinality | High — cache bypass or fragmented hits | Good — server-rendered |
| Edge rewrite with variant cache key | High — 2 entries per URL (control/treatment) | Near Zero — served from edge cache after warmup | Excellent — correct HTML on first byte |
| Edge redirect (302) | Medium — extra hop, uncacheable redirect | Medium — redirect logic hits edge, then cache | Poor — redirect flash, SEO risk |
Preserving Experiment Fidelity: Analytics, Logging, and Cache Purging Strategies
An experiment you can't measure correctly is worse than no experiment. When variants are served from edge cache, your origin access logs will show nothing — which is exactly what you want for overhead — but you still need exposure events for your analytics warehouse. I solve this by emitting a log or beacon at the edge, not at the origin.
Every variant response should include a response header like x-experiment: pdp_checkout_2024_11=treatment and a cookie as described above. At the same time, the edge worker should asynchronously log the exposure to your analytics pipeline. On Cloudflare Workers that's ctx.waitUntil(fetch('https://log.example.com/exposure', {method:'POST', body: JSON.stringify({experiment, variant, userId, timestamp})})). On Fastly Compute@Edge, use a logging endpoint. I also inject a tiny <meta name="x-experiment" content="..."> or a data layer push so that client-side analytics like Segment or GA4 can pick up the variant without an extra request. The key is that logging is fire-and-forget and never blocks the response.
Avoiding data loss when the edge cache serves the variant
I've found that teams lose count when they only log at origin. If 94% of traffic is served from edge cache, origin logs will undercount exposures by 94%. Edge logging captures 100% of exposures before the cache lookup completes. For debugging, I also force a sample of requests (1-2%) to bypass the edge assignment and hit origin with a diagnostic header so I can compare counts. In a recent audit, edge logs and client-side GA4 reconciled to within 0.3% once we moved logging to the edge, versus a 12% discrepancy with origin-only logging.
Purging and ramping without origin deploys
Edge experiments let you ramp traffic without deploying origin code. To ramp from 10% to 50%, you only change the allocation object in the edge worker and redeploy the worker in under 10 seconds. Purging is similarly surgical. Tag each variant's cache with experiment-pdp_checkout_2024_11-control and experiment-pdp_checkout_2024_11-treatment. When the experiment concludes and treatment wins, purge the control tag and rewrite the worker to serve treatment to 100% for a week before you clean up the origin templates. This gives you an instant rollback if metrics regress. For more on assembling dynamic fragments at the edge, see Edge-Side Includes in Modern CDNs: Dynamic Content Assembly at the Edge, which uses the same surrogate key technique.
Avoiding Flicker, SEO Cannibalization, and Bot Contamination in Edge Tests
The three Silent killers I've seen in edge experiments are layout shift from late variant application, search engines indexing the wrong variant, and bots polluting your results. All three are preventable at the edge.
Flicker disappears when you serve the correct HTML on first byte, which edge rewrites do by default. There is no client-side document.write or CSS hide-and-reveal. To guarantee this, never fall back to client-side mutation for the same experiment. If the edge assigns treatment, the HTML that leaves the PoP must already contain the treatment markup. Test this with curl -H "Cookie: exp_pdp_checkout_2024_11=treatment" and inspect the raw HTML — if you see control markup with a script that swaps it, you have not actually moved the experiment to the edge.
// Edge SEO and bot handling - set before returning response
function applyExperimentHeaders(response, variant, experimentId) {
// Bots always get control and are excluded from analytics
// This keeps crawl budget clean and avoids indexing variants
const headers = new Headers(response.headers);
// Prevent variant URLs from being indexed if rewrite uses query param
if (variant !== 'control') {
// Keep canonical pointing to clean URL
// Add header for search engines that respect it
headers.set('Link', '<https://example.com' + new URL(response.url).pathname + '>; rel="canonical"');
headers.set('X-Robots-Tag', 'index, follow');
}
// Tell downstream caches and browsers how to vary
// Use custom header, not Cookie, to avoid fragmentation
headers.set('Vary', 'x-edge-variant, Accept-Encoding');
headers.set('Cache-Control', 'public, max-age=3600, stale-while-revalidate=60');
// Expose variant for client-side analytics without extra JS
headers.set('Server-Timing', `exp;desc="${experimentId}=${variant}"`);
return new Response(response.body, { status: response.status, headers });
}
Handling bots, crawlers, and prefetch requests
Always exclude known bots from experiments. Check the User-Agent against a maintained bot list and force control with no logging. I also exclude Purpose: prefetch and Sec-Purpose: prefetch requests from assignment — if Chrome prefetches /products/blue-widget in the background, you don't want that to count as an exposure or to set a variant cookie that the user never actually saw. The web.dev Articles on prefetch and prerender explain how often these speculative requests fire; in my traffic they account for 4-8% of document requests and would skew results if counted.
Canonical tags and structured data consistency
For SEO, every variant must return the same canonical URL in <link rel="canonical"> and the same structured data IDs. The only thing that should differ is the experiment-specific markup you are testing — a headline, a price layout, a checkout button. If you test different JSON-LD prices or availability, you risk Google Merchant Center mismatches. I've made it a rule to never experiment on structured data or canonical logic at the edge; keep those identical across variants and only vary the human-visible presentation.
Deciding Between Edge-Native Experiments and Client-Side Frameworks
Not every test belongs at the edge. In my experience, edge-native experiments excel when the change is structural to the HTML — layout, pricing presentation, navigation, or any above-the-fold content where flicker would ruin the user experience or Core Web Vitals. They also win when you need to protect origin capacity or cache efficiency at scale, which is almost always for high-traffic pages.
Client-side frameworks still make sense for low-risk UI tweaks that are triggered after interaction — tooltip copy, modal sequencing, or personalization that depends on client-side state the edge can't see, like scroll depth or localStorage history. The tradeoff is flicker and an extra JavaScript payload. If your test changes CSS or text that appears before DOMContentLoaded, that flicker will show up in your CLS and LCP. The edge rewrite approach avoids that entirely because the variant is baked into the first HTML bytes.
There's a middle ground where teams use edge assignment but client-side rendering. I advise against it. If you assign at the edge but render with JavaScript, you pay both costs: you add edge logic without gaining cache efficiency or visual stability. Either commit to edge rewrites with variant-specific cache keys, or keep the test fully client-side for a quick learning. For authentication-gated experiments where you need to verify identity before bucketing, combine the approach with Edge Authentication: JWT Verification and Token Validation at CDN Layer so the edge can validate the session and bucket in one pass without ever hitting origin.
If you are exploring compiled modules for heavier bucketing logic like multi-armed bandits or ML-driven allocation, WebAssembly at the Edge: Running Wasm Modules on CDN Networks is worth reading. I've prototyped a Wasm-based Thompson sampling allocator that runs in under 2ms at the edge, which is impossible with a JSON fetch to an external service on every request.
Frequently Asked Questions
How do you keep A/B assignments sticky when users clear cookies or switch devices?
For anonymous users, cookie clearance will naturally reset the assignment — there's no cross-device magic without an authenticated ID. That's acceptable if your analysis is per-session. For logged-in users, hash on the stable user ID instead of the anonymous cookie so the assignment follows them across devices and browsers. I set both cookies and prefer the authenticated ID when present, falling back to the anonymous ID only for guests. Expect 5-10% of anonymous users to get re-bucketed monthly due to cookie churn; document that in your experiment readout.
Will edge A/B testing double my cache storage costs?
It increases storage by the number of variants, not traffic. A 50/50 test on one URL creates two cache objects instead of one. For a site with 100,000 cacheable URLs and one concurrent test on 5% of them, you're storing 5,000 extra objects — negligible on any commercial CDN. The hit ratio improvement from removing Vary: Cookie almost always outweighs the extra storage. I have never seen a CDN bill increase materially from variant cache keys; I have seen origin bills drop 30-50% because the edge could finally cache effectively.
How do you handle experiments that need to read from an origin feature flag service?
Don't read that service on every request. Replicate the flag configuration to the edge via KV, Workers Config, or an edge dictionary and update it via webhook when flags change. The bucketing function should read from that local store synchronously. If you must fetch remotely, do it asynchronously and cache the response at the edge for 30-60 seconds with stale-while-revalidate so a flag service outage doesn't take down your site. In production I keep flag polling out of the request path entirely — a scheduled worker fetches flags every 15 seconds and writes to KV, and request workers only read KV.
Do I need to worry about GDPR or consent before setting experiment cookies?
Yes, and this is where edge logic helps. I set experiment cookies as strictly necessary for service functionality when the test is about site performance or core UX, but for marketing or personalization tests, I gate bucketing on your consent signal. Read the consent cookie at the edge — if consent is not granted for analytics, force control and do not set the variant cookie or log an exposure. This keeps you compliant without requiring origin changes. Always document your experiment cookies in your cookie policy and keep their expiry aligned with the test duration.