Last spring we pushed a new image optimization service to 5% of our CDN traffic and watched our origin error rate double within ten minutes. The canary caught it, we rolled back, and nobody outside our Slack channel noticed. That’s the whole point of traffic splitting — you don’t ship blind. After a decade of running CDN edges that handle everything from Black Friday spikes to bot floods, I’ve learned that how you slice the traffic matters just as much as how much you slice.
Percentage Splits: More Than Just Rolling Dice
Percentage routing is usually the first strategy teams reach for, and for good reason. You tell the load balancer to send, say, 90% of requests to your stable v1 stack and 10% to the new v2. It sounds simple, but the implementation details determine whether your results are actually useful. I’ve seen teams use naive random number generation per request and then wonder why their A/B test results were skewed during peak hours.
The problem with pure random is inconsistency for cache efficiency and user experience. If the same user hits a different backend on every request, you’ll thrash your cache and break session assumptions. What you want is a deterministic hash — usually on a stable request attribute like client IP prefix or a normalized URL — then map the hash space to percentages. This gives you a stable split that’s still statistically accurate at scale. It also plays much better with downstream caching, since repeat requests for the same asset consistently route to the same origin group. When I helped a media customer migrate their video origins, we moved from random to hash-based percentage splits and saw cache hit ratio climb from 82% to 91% overnight without changing anything else.
If you’re already familiar with Weighted Round-Robin for CDN: Proportional Traffic Distribution Across Heterogeneous Servers, think of percentage-based traffic splitting as its close cousin for CDN edge routing. The difference is that at the edge you’re often splitting at layer 7 across entire origin pools or service versions, not just individual servers. Most modern CDNs expose this as a native traffic policy, but you can also build it at the proxy layer. Here’s a pattern I still use with Nginx/OpenResty to get deterministic percentage routing without an external control plane:
# deterministic 90/10 split based on request URI + IP
split_clients "${remote_addr}${uri}" $cdn_split_backend {
90% origin_v1;
10% origin_v2;
}
upstream origin_v1 {
server origin-v1.example.com:443;
}
upstream origin_v2 {
server origin-v2.example.com:443;
}
server {
location / {
proxy_pass https://$cdn_split_backend;
proxy_set_header Host $host;
# Add a header so you can verify the split downstream
add_header X-Split-Backend $cdn_split_backend always;
}
}
Header-Based Routing: Precision Control for API and Canary Workflows
When percentage routing gives you statistical distribution, header routing gives you surgical precision. Instead of saying “send 10% of traffic to v2,” you say “send traffic with X-Release-Track: canary to v2, everything else to v1.” I’ve relied on this pattern for years when we need to let internal QA, specific partners, or mobile app beta users hit new infrastructure while the public stays on the stable path. It’s deterministic, auditable, and trivial to debug because you can see exactly why a request routed where it did.
The key is choosing headers that are both intentional and hard to spoof accidentally. Don’t use generic headers like User-Agent for critical splits — you’ll get false positives from crawlers and outdated clients. Custom headers like X-API-Version: 2024-05-01, X-Edge-Experiment: image-avif, or even standard headers like Accept-Encoding when used carefully, work far better. We once ran a header-based split for an AVIF rollout where only requests with Accept: image/avif and our X-Image-Opt-In: 1 header were routed to the new image processing cluster. It let our frontend team control the blast radius with a simple feature flag, no load balancer config change required. Just remember to normalize and validate headers per the HTTP Working Group Specifications — header names are case-insensitive, values may be case-sensitive, and you’ll want to strip or ignore whitespace before matching.
This approach also shines in multi-region setups. Paired with GSLB Implementation Patterns: Global Server Load Balancing for Multi-Region CDN, header routing lets you steer traffic to a specific region or stack for testing without affecting global percentage weights. The snippet below is a Cloudflare Workers / VCL-style example I’ve adapted for several edge platforms. It checks for a canary header first, falls back to percentage logic only if no header is present, and always sets a response header for observability — a habit that’s saved me hours of debugging.
export default {
async fetch(request, env) {
const url = new URL(request.url);
// 1. Header-based override - highest priority
const track = request.headers.get("x-release-track");
if (track === "canary") {
// Validate header value strictly
const response = await fetch(request, { backend: "origin_v2" });
response.headers.set("X-CDN-Split", "header:canary");
return response;
}
if (track === "stable") {
const response = await fetch(request, { backend: "origin_v1" });
response.headers.set("X-CDN-Split", "header:stable");
return response;
}
// 2. Fallback to percentage split if no routing header
const hash = await hashString(request.headers.get("cf-connecting-ip") + url.pathname);
const backend = (hash % 100) < 10 ? "origin_v2" : "origin_v1";
const response = await fetch(request, { backend });
response.headers.set("X-CDN-Split", `percent:${backend}`);
return response;
}
}
async function hashString(str) {
const data = new TextEncoder().encode(str);
const hash = await crypto.subtle.digest("SHA-256", data);
return new DataView(hash).getUint32(0, false);
}
Cookie-Based Stickiness: Keeping Users on Their Assigned Experience
Cookies solve the one problem that breaks both percentage and header splits: user stickiness across multiple requests. If you’re running an A/B test on your checkout flow or gradually migrating users to a new rendering service, you can’t have a user flip between v1 and v2 on every refresh. I learned this the hard way during a 2019 e-commerce replatform — our percentage split was technically perfect, but users complained about flickering layouts because their first request went to v1 and their third went to v2. Switching to a cookie-based split with a 7-day sticky assignment eliminated the complaints and made our conversion data actually trustworthy.
The flow is straightforward. On the first request, the edge assigns the user to a variant based on your split logic, sets a cookie like cdn_split=v2; Path=/; Max-Age=604800, and routes accordingly. On every subsequent request, the edge reads that cookie and honors it before evaluating any percentage rules. You’ll want to set HttpOnly and Secure flags, keep the cookie value short, and consider SameSite=Lax to avoid cross-site issues. The web.dev Articles on cookie best practices are a solid checklist here. One nuance I always flag: cookie-based splits don’t work well for anonymous, cookieless traffic like images or API calls from dumb clients. For those, keep a header or percentage fallback. And be careful with cache keys — if you vary the response based on the cookie, you must add Vary: Cookie or better, use a surrogate key like Vary: X-CDN-Split to avoid cache poisoning.
Don’t confuse this with session affinity at the origin. Cookie-based traffic splitting at the CDN is about assigning an experience, not pinning a user to a specific server for connection reuse. For that low-level server pinning, algorithms like Least-Connections Load Balancing for CDN: Dynamic Distribution Based on Server Load still handle the final hop. I’ve measured the tradeoffs across hundreds of millions of requests, and the choice really comes down to what you’re optimizing for:
| Strategy | Best For | Cache Hit Ratio Impact | Added Latency (p50) |
|---|---|---|---|
| Percentage (hash-based) | Canary releases, infra migrations | -2% to -4% vs baseline | 0.1 - 0.3 ms |
| Header-Based | Internal testing, API versioning | ~0% (deterministic, cache-friendly) | 0.05 - 0.15 ms |
| Cookie-Based (sticky) | A/B tests, UX experiments | -5% to -8% with Vary: Cookie | 0.2 - 0.5 ms + cookie parse |
| Hybrid (Cookie → Header → %) | Production-grade gradual rollouts | -2% to -3% with proper keying | 0.25 - 0.6 ms |
From Config to Edge: Practical Implementation Blueprint
You've got the theory, but the edge doesn't care about theory — it cares about deterministic execution in under 1ms. The biggest mistake I see teams make is implementing percentage splits with Math.random() on every request. Don't. That gives you statistical distribution, not sticky, predictable routing, and it'll tank your cache hit ratio by 30-40% because the same URL keeps bouncing between origins. Instead, you want a consistent hash of a stable identifier — user ID, session token, or IP as a last resort — modulo 100. That way user 12345 always lands in bucket 73, your edge cache stays hot, and you don't get paged at 2 a.m. because your origin QPS doubled for no reason.
At the CDN layer — whether it's Fastly VCL, NGINX, or a Cloudflare Worker — you'll want a strict precedence chain. We typically evaluate it like this: header match first, then existing sticky cookie, then weighted percentage hash as the fallback. If x-canary: v2 is present, route immediately and skip everything else. If a cdn_split cookie already exists, honor it for 7 days with SameSite=Lax; Path=/; Max-Age=604800. Only if neither exists do you hash and assign. And here's where folks get burned: cookie-less clients. Bots, curl, and most API consumers will never store that cookie, so they'll re-roll on every request. That's where IP Hash for CDN Session Persistence: Consistent User Routing Without Cookies is a lifesaver as a zero-dependency fallback, though you'll need to account for carrier-grade NAT where 5,000 users share a single IP.
// Cloudflare Worker / Fastly Compute@Edge - Deterministic Split Logic
const ORIGINS = { stable: "origin-v1.example.com", canary: "origin-v2.example.com" };
function getBucket(key) {
// FNV-1a hash - fast, deterministic, <0.2ms at edge
let hash = 2166136261;
for (let i = 0; i < key.length; i++) {
hash ^= key.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return Math.abs(hash) % 100;
}
export default {
async fetch(req) {
// 1. Header-based override (highest priority)
if (req.headers.get("x-canary") === "v2") {
return fetch(req, { backend: ORIGINS.canary });
}
// 2. Cookie stickiness
const cookie = req.headers.get("cookie")?.match(/cdn_split=v2/);
if (cookie) return fetch(req, { backend: ORIGINS.canary });
// 3. Percentage rollout - 5% canary
const id = req.headers.get("x-user-id") || req.headers.get("cf-connecting-ip");
const bucket = getBucket(id);
const isCanary = bucket < 5;
const res = await fetch(req, { backend: isCanary ? ORIGINS.canary : ORIGINS.stable });
if (!cookie) {
res.headers.set("Set-Cookie", `cdn_split=${isCanary ? "v2" : "v1"}; Max-Age=604800; Path=/; SameSite=Lax`);
res.headers.set("x-split-decision", isCanary ? "percent-canary" : "percent-stable");
}
return res;
}
}
Advanced Patterns and Troubleshooting When Splits Go Sideways
Once you're past a basic 95/5 canary, you'll want weighted failover that doesn't flap. Don't just do active-passive — run active-active with weighted health checks. We run 90% to v1 and 10% to v2 in steady state, but if v2's p95 latency breaches 350ms or its 5xx rate exceeds 0.5% for 60 seconds, we automatically drain it to 0% in under 10 seconds. This only works if your Health Check Design Patterns for CDN: Shallow, Deep, and Synthetic Checks are solid. A shallow /healthz returning 200 isn't enough; your canary checks need to hit a deep endpoint that actually renders a page and queries the DB, otherwise you'll route live users to an origin that can serve health checks but not real traffic.
The other pattern that saves you at scale is isolating your hash ring. When you add or remove a canary origin, a naive modulo hash will reshuffle 80-90% of users to different buckets, effectively busting persistence and spiking origin load. For any setup with more than 3-4 backends, you should be using a Consistent Hash Ring Architecture for CDN: Minimizing Redistribution on Server Changes. With a proper ring and 150-200 virtual nodes per physical origin, adding a new node only moves ~1/N keys — so with 10 origins, you only remap about 10% of traffic instead of nearly everyone. It's the difference between a smooth 2% error rate bump and a full cache stampede that takes 15 minutes to recover from.
When splits misbehave, check these three culprits first. One, cookie domain and path mismatches — if your app sets .example.com and your edge sets www.example.com, the browser stores two cookies and you get flickering. Two, header case sensitivity. Your origin might look for X-Canary while the edge normalizes to x-canary, so your 1% internal test never actually hits the canary. And three, cache poisoning. If you don't include your split decision in the cache key via Vary: Cookie, x-canary or a custom Surrogate-Key, a canary response can get cached and served to stable users for the next 24 hours. Always emit a x-split-decision response header and log it — you can't debug what you can't see in your CDN logs.
Frequently Asked Questions
How long should I keep a user sticky to a canary version?
For most web apps, 7 to 30 days is the sweet spot. Shorter than 24 hours and you'll skew your A/B metrics as users flip-flop between versions. Longer than 30 days and you're holding onto stale assignments long after the experiment is over. Set your Max-Age to 604800 (7 days) and refresh it on every response. For mobile apps or authenticated sessions, tie stickiness to the session lifetime instead — don't set a 30-day cookie if your JWT expires in 2 hours.
Can I combine percentage, header, and cookie splitting at the same time?
Absolutely — and you should. Think of it as a waterfall: header checks win first (for internal testing and API clients), then cookie stickiness (for returning users), then percentage hash (for new users). We've run configs where employees with x-env: staging always hit canary, beta users with a cookie stay sticky, and the remaining 94% of anonymous traffic gets a 5% hash-based canary. Just make sure your precedence is documented and logged, or you'll never untangle why a request went where it did.
Why are my canary metrics noisy even with a 10% split?
You're likely not hashing on a stable key. If you're hashing on IP alone, a single office NAT can dump 200 users into the same bucket, skewing conversions. If you're using random assignment per request, you're not just noisy — you're also destroying cache efficiency. Use a high-cardinality, stable ID like an authenticated user ID or a first-party anonymous ID cookie. And give it time; at 10% with 50k requests per minute, you need at least 2-3 hours to reach 95% statistical significance on latency deltas under 20ms.
Do I need to Vary cache on cookies for sticky splits?
Yes, but do it surgically. Don't do Vary: Cookie — that fragments your cache on every single cookie and can drop your hit ratio from 92% to 55% overnight. Instead, vary only on the specific cookie that drives the split, e.g., Vary: Cookie: cdn_split on Fastly, or better yet, normalize it to a request header at the edge and vary on that. Even cleaner: use Surrogate-Key or Cache-Key rewriting to add split=v1/v2 without exposing the raw cookie to the cache key.