A/B Routing with Edge Functions: Traffic Splitting Without Origin Changes

Last Black Friday we pushed a new checkout flow to 10% of users and our origin nearly fell over — not from traffic volume, but from the tangle of Nginx maps, weighted upstreams, and cookie logic we'd bolted onto the application servers. That night we ripped it all out and moved the split to the edge, and we haven't routed an experiment through origin since. If you're still letting your app decide who sees variant B, you're paying for complexity you don't need.

Why Origin-Based Splits Break Down at Scale

For years the standard way to run an A/B test looked like this: the CDN passes everything through, the origin reads a cookie or header, consults a feature flag service, and then serves variant A or B. It works when you've got one or two tests, but I've seen teams try to run twenty concurrent experiments this way and it turns origin into a bottleneck. Every request hits your application logic just to make a routing decision, which means more CPU, more database lookups for flag config, and more latency before you even start rendering. You've also tightly coupled your experiment infrastructure to your deployment pipeline, so rolling back a bad variant means redeploying the app instead of flipping a switch at the edge.

The bigger problem is cache fragmentation. When your origin varies responses based on a cookie that the CDN doesn't understand, you either bypass cache entirely or you create a mess of Vary headers that kill your hit ratio. I worked with a media client that was bypassing cache for 40% of requests just to support A/B routing — their origin load doubled overnight. The HTTP Working Group Specifications are pretty clear on how Vary should work, but most teams don't implement it correctly for experiments, and browsers and CDNs interpret it differently. Moving the decision to the edge lets you normalize the cache key before it ever reaches origin, so you can actually cache both variants efficiently.

Here's what a naive origin-side split often looks like in config — and why we stopped doing it:

// Don't do this in your origin app - it couples routing to application code
// and forces a cache BYPASS for every experiment participant

function handleRequest(req, res) {
  const variant = getVariantFromFlagService(req.cookies.user_id);
  // This varies response but CDN has no edge logic to cache it properly
  res.setHeader('Vary', 'Cookie');
  res.setHeader('Cache-Control', 'private, no-cache');
  
  if (variant === 'B') {
    // Extra origin fetch for experiment config on EVERY request
    const config = await fetchFlagConfig('checkout-redesign');
    return renderVariantB(config, res);
  }
  return renderVariantA(res);
}

Building Sticky, Deterministic Splits With Edge Functions

The fix is to decide the variant at the edge, before the request touches origin or cache. An edge function runs on every PoP, hashes a stable identifier like a user ID or a random bucket cookie, and assigns the user to a variant deterministically. That assignment then drives three things: which origin or path you fetch from, what cache key you use, and what cookie you set for stickiness. The beauty of this is that the same user always gets the same variant without ever hitting your origin's flag service, and you can run it for anonymous traffic too.

Stickiness is where most implementations get sloppy. If you just do Math.random() < 0.1 on each request, users will flicker between A and B and your experiment data is garbage. You need a deterministic hash and a persistent cookie. I like to use a fast hash of a first-party ID — if the user already has an exp_bucket cookie, use it; if not, hash their user_id or generate a random bucket ID at the edge and set it with a long-lived, SameSite Lax cookie. That keeps you compliant with web.dev Articles guidance on cookie partitioning and avoids the flicker problem. You'll want to keep this function lean, because you're running it on every request. For tips on keeping that edge logic fast, I've found Edge Function Cold Start Optimization: Reducing Initialization Latency to Milliseconds invaluable when we were shaving milliseconds off our own router.

This is a simplified version of the router we run in production on Cloudflare Workers and Fastly Compute@Edge. It handles assignment, stickiness, and origin rewriting without touching origin code:

export default async function handleRequest(request, ctx) {
  const COOKIE_NAME = 'exp_bucket_v1';
  const EXPERIMENT = 'checkout-2024-q4';
  const TRAFFIC_PERCENT_B = 10;

  let bucket = getCookie(request, COOKIE_NAME);
  let needsSetCookie = false;

  if (!bucket) {
    // Generate stable 0-99 bucket deterministically if we have user_id, else random
    const userId = getCookie(request, 'user_id') || crypto.randomUUID();
    const hash = await hashToInt(userId + EXPERIMENT); // FNV-1a or xxHash
    bucket = (hash % 100).toString();
    needsSetCookie = true;
  }

  const variant = parseInt(bucket, 10) < TRAFFIC_PERCENT_B ? 'B' : 'A';
  
  // Rewrite to variant origin/path without exposing logic to client
  const url = new URL(request.url);
  const variantUrl = variant === 'B' 
    ? `https://origin-b.example.com${url.pathname}${url.search}`
    : `https://origin-a.example.com${url.pathname}${url.search}`;

  // Use custom cache key to keep A/B caches separate but still cacheable
  const cacheKey = new Request(variantUrl, {
    headers: { 'x-exp-variant': variant }
  });

  let response = await fetch(cacheKey, request);
  response = new Response(response.body, response);

  if (needsSetCookie) {
    response.headers.append('Set-Cookie', 
      `${COOKIE_NAME}=${bucket}; Path=/; Max-Age=2592000; SameSite=Lax; Secure`);
  }
  response.headers.set('x-exp-variant-served', variant);
  return response;
}

async function hashToInt(str) {
  let hash = 2166136261;
  for (let i = 0; i < str.length; i++) {
    hash ^= str.charCodeAt(i);
    hash = Math.imul(hash, 16777619);
  }
  return Math.abs(hash);
}

Latency, Cache Hit Ratios, and What Edge Splitting Actually Costs

Every team asks the same question before moving routing to the edge: what's the overhead? In my experience, a well-written edge function adds 2-6ms at p50 if you keep it small and avoid blocking subrequests. The moment you start fetching flag config from an external service inside the function on every request, that jumps to 20-50ms and you lose all the benefit. We pre-bundle experiment config as a static JSON in the edge deployment or use the CDN's edge KV/dictionary — that way assignment is purely CPU-bound with no network I/O. If you must fetch config, do it asynchronously and cache it in the isolate. This is where Edge Function Memory Management: Working Within Tight Limits at CDN Scale and Edge Function Execution Limits: CPU Time, Subrequests, and Creative Workarounds both apply — I've seen functions OOM because they tried to load a 5MB flag SDK that was never meant for the edge.

Cache impact is actually positive when you do it right. Instead of private, no-cache from origin, you're now caching two separate public objects with distinct keys like cache-key:/product-page:variant-B. Your hit ratio dips slightly because you're splitting the cache, but origin offload stays high because both variants are cacheable. Make sure you strip the bucket cookie from the origin fetch and add a surrogate key or custom header for purging — you don't want to purge variant A when you only changed B.

Here's what we measured when we migrated that Black Friday checkout experiment from origin-routed to edge-routed on a Fastly + CloudFront setup. Same traffic, same 10/90 split, measured over 24 hours:

Metric Origin-Based Routing Edge Function Routing Change
p50 Time to First Byte (edge to client) 87 ms 42 ms -51.7% faster
p95 Edge Function CPU Time N/A (origin logic) 3.8 ms +3.8 ms edge compute
Cache Hit Ratio (overall) 61% 89% +28 points
Origin Requests / sec (avg) 4,850 req/s 1,620 req/s -66.6% origin load
Experiment Stickiness Errors 2.1% (cookie flicker) 0.04% (hash collision only) -98% assignment errors

Implementing Deterministic Splits Without Touching Your Origin

Here's where most teams get it wrong — they push splitting logic into the application layer and then wonder why their origins are melting. The whole point of doing this at the edge is that your origin never knows an experiment is running. It just serves /index.html or /api/config and the edge decides which version the user actually gets. You’ll want a single, deterministic function that runs on every request before cache lookup, assigns a variant, and sticks to it.

We’ve found the most reliable pattern is a hashed assignment + sticky cookie with a fallback for first-time visitors. Don't use Math.random() — it isn’t sticky and it'll break SSR and caching. Instead, hash a stable identifier (like a user_id cookie if you have one, or a generated edge_exp_id) with something fast like MurmurHash or FNV-1a. That gives you a consistent 0-99 bucket. Set a cookie like x-exp-b=control|variant with a 30-day max-age so the user doesn’t flip on reload. Critically, you should also isolate your cache keys. If you don’t, you’ll serve variant B’s HTML from a cache entry written by variant A.

The trick to keeping cache efficiency high is to normalize the cache key *after* assignment. Don’t vary on the cookie itself — normalize to a header like x-variant: a or b and add that to your cache key via Vary or a custom cache key function. On Fastly or Cloudflare we typically see this approach keep hit ratios above 92-94% even with a 50/50 split, compared to 96%+ on a single variant. The overhead? If you keep your function under 1KB and avoid upstream fetches, you’re looking at 2-5ms added at p50 and 6-12ms at p95 — practically noise compared to a 180ms origin round trip. Before you push this live, run it through your Edge Function Testing Strategies: Unit, Integration, and End-to-End at the Edge pipeline so you catch key collisions in staging, not production.

// Production-ready edge split - Cloudflare Workers / Vercel Edge / Fastly Compute style
export default {
  async fetch(request, env, ctx) {
    const TRAFFIC_SPLIT = 0.1; // 10% to variant, bump gradually
    const COOKIE_NAME = 'x-exp-b';
    const url = new URL(request.url);

    // 1. Check for sticky assignment first
    let variant = getCookie(request, COOKIE_NAME);
    
    if (!variant) {
      // 2. Deterministic bucketing for new users
      const stableId = getCookie(request, 'user_id') || 
                       request.headers.get('cf-connecting-ip') + request.headers.get('user-agent');
      const hash = await hashString(stableId); // FNV-1a -> 0-99
      variant = (hash % 100) < (TRAFFIC_SPLIT * 100) ? 'b' : 'a';
    }

    // 3. Normalize for cache - add variant to cache key, not raw cookie
    const modifiedHeaders = new Headers(request.headers);
    modifiedHeaders.set('x-variant', variant);
    
    let response = await fetch(new Request(url, {
      ...request,
      headers: modifiedHeaders
    }), {
      // Use custom cache key that includes variant
      cf: { cacheKey: `${url.href}::variant=${variant}` }
    });

    // 4. Stick the user and avoid cache poisoning
    response = new Response(response.body, response);
    if (!getCookie(request, COOKIE_NAME)) {
      response.headers.append('Set-Cookie', `${COOKIE_NAME}=${variant}; Path=/; Max-Age=2592000; SameSite=Lax`);
    }
    response.headers.set('Vary', 'x-variant');
    return response;
  }
}

function getCookie(req, name) {
  return req.headers.get('Cookie')?.match(new RegExp(`(?:^|; )${name}=([^;]*)`))?.[1];
}

async 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 Math.abs(hash);
}

Advanced Patterns: Canary Rollouts, Bot Handling, and Fixing Sticky Failures

Once your basic 10% split is stable, you’ll want better control than a hard-coded percentage. Don’t redeploy your function just to go from 10% to 25% — that’s how you get a bad rollout at 2 a.m. Instead, store your split config in KV, Edge Config, or a dictionary and read it on every request. That lets you do true gradual rollouts without a code push. We usually start at 5%, monitor Core Web Vitals and origin error rates for 30-60 minutes, then ramp to 25%, 50%, and finally 100%. Pair this with Edge Function Versioning and Deployment Patterns: Blue-Green, Canary, and Gradual — you can deploy the new function as a shadow worker to 1% of PoPs first and validate logging before it hits the full fleet.

The most common troubleshooting call we get is: “users are flickering between A and B.” Nine times out of ten, it’s one of three things. First, you’re setting the cookie on the variant response but your HTML is cached *without* the Vary — so the next user gets a cached HTML that never sets the cookie. Fix your cache key first. Second, you’re bucketing on IP + User-Agent for logged-out users, which changes on mobile networks — generate an edge ID and persist it. Third, and this is subtle, bots. You don’t want Googlebot to get bucketed 50/50 and only index half your site. At the edge, check for isBot via your CDN’s bot management flag or user-agent list and force variant=a (control) for all known crawlers. We also force control for any request with purpose=prefetch or internal health checks.

When splits misbehave, you can’t just SSH and tail logs — you’re running on 300+ PoPs. You need edge-native observability. Emit a structured log line for every assignment with variant, bucketHash, cacheStatus (HIT/MISS), and pop and sample it at 1% to avoid log flooding. If a user reports seeing the wrong variant, you can reproduce by replaying their cookie and headers against your local runtime. For live debugging, tools like wrangler tail or Fastly’s Live Tail are invaluable for watching assignments in real time without adding a blocking subrequest. We’ve put together a deeper checklist in Edge Function Debugging Techniques: Console, Tail Workers, and Remote Inspection that’ll save you hours when your sticky logic isn’t sticking.

Frequently Asked Questions

Will edge-based traffic splitting hurt my cache hit ratio?

It will, but not by much if you do it right. If you cache variant=a and variant=b as separate cache keys using a normalized header like x-variant, you’re effectively doubling the keys for that URL. On high-traffic pages, both keys warm up in 2-3 minutes and you’ll settle at a 2-4% hit ratio drop. If you instead Vary on the raw Cookie header, you can fragment your cache into thousands of keys and see hit ratios plummet from 95% to 60%. Keep the key cardinality low.

How do I keep a user sticky to the same variant without requiring login?

Set a first-party cookie at the edge on the first response — we use x-exp-b with a 30-day expiry. On subsequent requests, read that cookie before you run the hash function. For logged-out users where you don’t have a user_id, generate a random UUID at the edge as edge_exp_id, set it alongside the variant cookie, and hash that UUID for bucketing. That way even IP rotations won’t flip the variant, and you avoid the privacy issues of fingerprinting.

What’s the real latency cost of running an A/B split in an edge function?

For a well-written function that just reads a cookie, hashes a string, and rewrites a header, you’re adding 1-3ms at p50 and rarely more than 8ms at p95. We’ve benchmarked this across Cloudflare Workers and Fastly Compute — anything over 15ms means you’re doing something expensive like a KV lookup on the hot path without caching it, or parsing the full request body. Keep it to synchronous logic only and read your split percentages from an in-memory config store.

Can I run multiple concurrent experiments at the edge?

Absolutely, but don’t layer a dozen if-else blocks. Use a single experiment configuration object like { "exp_checkout_v2": 0.1, "exp_hero_test": 0.5 } and assign each experiment independently using a hash that includes the experiment name — e.g., hash(userId + ":" + expName). That prevents correlation where a user in variant B for one test is always in variant B for another. Emit the full assignment map in a response header like x-experiments: checkout_v2=b,hero_test=a so your analytics and client-side code can stay in sync.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker