Edge & CDN Flag Delivery
This guide is part of the Frontend Integration & Client-Side Rendering series. Edge evaluation moves flag resolution out of the browser and out of the origin — the CDN worker intercepts the request, resolves the variant, injects a bootstrap payload, and forwards a personalized response, all before the first byte reaches the client. That means zero client-side latency for flag initialization and no UI flicker on first paint.
The challenge is doing that without collapsing all variants into one cached response or poisoning a shared CDN cache with a targeted payload that only one audience segment should see. A CDN is a cache first and a compute platform second — the moment you inject per-audience content into a response the cache would otherwise share, you inherit every cache-correctness problem that HTTP caching has spent thirty years accumulating. Get the cache key wrong and the failure is silent: the site looks fine to you because you are the first visitor, and every visitor after you inherits your cohort. That failure mode — correct in staging, quietly wrong under real traffic — is why edge flag delivery deserves more care than a client-side SDK swap, and why most of this guide is about the cache, not the evaluation.
Problem Framing: What Edge Delivery Solves (and What It Does Not)
Client-side flag initialization has a round-trip problem: the browser loads the page, the SDK fetches flags, and until that fetch resolves the UI renders the default variant. That causes the flash-of-default that hydration-mismatch guides spend considerable effort suppressing. Server-side rendering helps, but the origin still needs the resolved variant before it can render — which adds a server call on every cache miss.
Edge evaluation solves both: the CDN worker that already handles the request also resolves the variant, so the injected bootstrap payload travels with the HTML on the first response, with no extra round-trip. The economics are worth stating plainly — a client-side flag fetch to a well-provisioned edge endpoint still costs one TCP/TLS setup plus a request the browser cannot begin until the HTML has parsed far enough to run the SDK, which in practice means 50–200 ms of dead time on a cold connection during which the user stares at the default variant. Edge injection collapses that to zero because the worker is already inside the connection the browser opened for the document itself. The variant is decided in the same microtask that assembles the response headers, so time-to-first-variant is bounded by evaluation cost, not network cost.
What edge delivery does not cover:
- Client-side re-evaluation when user context changes mid-session (handle with a client-side SDK init fallback).
- Deeply personalized responses driven by server-held state that the edge cannot access.
- Fine-grained CSP nonce injection — that remains the origin’s job unless the edge worker is also rewriting
Content-Security-Policyheaders.
It also does not remove the need for a client-side SDK entirely. The bootstrap payload is a snapshot: it reflects the request-time context and nothing after it. If a user upgrades their plan, toggles a preference, or logs in during the session, the flags baked into the first byte are now stale, and only a client SDK reading fresh context can correct them. Treat the edge payload as the fast initial value that eliminates flicker, and the client SDK as the authority for anything that changes after page load — the two are complements, not substitutes.
Prerequisites
Core Concept & Architecture
Evaluation at the edge versus injection from origin
There are two patterns for getting flags into an edge-served response:
| Approach | Who resolves | Cache safety | Latency |
|---|---|---|---|
| Edge evaluation | Edge worker reads rule set locally | Worker partitions cache by cohort | Sub-millisecond, no origin call for cache hits |
| Origin injection + CDN pass-through | Origin resolves, sets header/cookie | Origin controls Vary; edge must respect it |
Origin latency on every miss |
| Client-side bootstrap only | Browser SDK fetches | CDN serves plain HTML; no variant in first byte | Extra network round-trip |
Edge evaluation gives the lowest time-to-first-variant at the cost of keeping the rule set replicated to every edge region. A hybrid works well: the edge resolves from a cached rule set for known request shapes, and falls back to origin for complex targeting that requires server-held state.
The decisive difference between the three rows is not latency — it is who owns cache correctness. With edge evaluation the worker both resolves the variant and writes the cache key, so a single piece of code is responsible for keeping them consistent; that is the safest arrangement because there is no seam for the two to drift apart. With origin injection the origin decides the variant but the edge owns the cache, so the two must agree on a Vary contract, and any CDN configuration that strips or ignores that header reintroduces the collapse-onto-first-visitor bug. Client bootstrap sidesteps cache partitioning entirely — the HTML is genuinely identical for every cohort — which is why it is the only pattern that is cache-safe by construction, and also the only one that pays a full client round-trip. Pick edge evaluation when time-to-first-variant matters and the targeting is expressible from request attributes; pick client bootstrap when the flag set is large, changes often, or depends on state the edge cannot see.
Cache-key partitioning
The most common mistake in edge flag delivery is forgetting to partition the CDN cache by resolved variant. A CDN without cache-key partitioning will serve the first cached response — with its embedded variant — to every subsequent request regardless of audience segment, collapsing all users onto the first visitor’s cohort.
Partition by appending the resolved cohort to the cache key. In Cloudflare Workers:
// workers/flag-delivery.js
// Flag key uses namespace.service.feature schema
const FLAG_KEY = 'web.storefront.new-checkout';
export default {
async fetch(request, env) {
// 1. Resolve variant from edge rule set
const cohort = await resolveVariant(request, env, FLAG_KEY);
// 2. Build a cohort-partitioned cache key
const cacheUrl = new URL(request.url);
cacheUrl.searchParams.set('_cohort', cohort);
const cacheKey = new Request(cacheUrl.toString(), request);
const cache = caches.default;
let response = await cache.match(cacheKey);
if (!response) {
// 3. Cache miss: fetch from origin, inject bootstrap, store under cohort key
response = await fetch(request);
response = injectBootstrap(response, FLAG_KEY, cohort);
// Only cache successful responses
if (response.status === 200) {
await cache.put(cacheKey, response.clone());
}
}
return response;
}
};
function injectBootstrap(response, flagKey, cohort) {
// HTMLRewriter injects a <script> into <head> before the browser parses
return new HTMLRewriter()
.on('head', {
element(el) {
el.prepend(
`<script>window.__FLAG_BOOTSTRAP__=${JSON.stringify({ [flagKey]: cohort })};</script>`,
{ html: true }
);
}
})
.transform(response);
}
async function resolveVariant(request, env, flagKey) {
const rules = await env.FLAG_KV.get('rules', { type: 'json' });
const rule = rules?.[flagKey];
if (!rule) return rule?.defaultVariant ?? 'control';
const geo = request.cf?.country ?? 'XX';
const tier = getCookieValue(request, 'user_tier') ?? 'free';
// Simple targeting: staff tier always gets beta
if (tier === 'staff') return 'beta';
// Percentage rollout keyed on targeting stable hash
const hash = simpleHash(`${flagKey}:${request.headers.get('cf-connecting-ip') ?? 'anon'}`);
if (rule.rolloutPct && (hash % 100) < rule.rolloutPct) return rule.treatmentVariant;
return rule.defaultVariant ?? 'control';
}
function getCookieValue(request, name) {
const cookie = request.headers.get('Cookie') ?? '';
const match = cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
return match ? match[1] : null;
}
function simpleHash(str) {
let h = 0;
for (const c of str) h = (Math.imul(31, h) + c.charCodeAt(0)) | 0;
return Math.abs(h);
}
Pitfall: if the edge injects variant-specific content but the Cache-Control header allows a shared CDN cache to collapse responses, every visitor after the first will see the same variant. Always ensure the cache key includes the cohort identifier — either via Vary on a custom request header you set before fetch, or via a custom cache key as shown above.
A subtler failure hides in the cardinality of the cache key. The cohort you append must be a small, closed set of variant names — beta, control, staff — and never the raw targeting attribute. If you accidentally key on something high-cardinality, such as the visitor’s IP, country-plus-tier tuple, or a session identifier, you fragment the cache into one entry per user and your hit rate collapses to near zero: every request becomes a miss, every miss hits origin, and you have quietly converted your CDN into an expensive reverse proxy. The rule of thumb is that the cache key should gain exactly as many distinct values as the flag has variants, not as many as the audience has members. Resolve the messy attributes down to a variant name first, then key on the variant.
Step-by-Step Implementation
Step 1 — Store the flag rule set in Workers KV with a short TTL
Bundle a lightweight rule set at the edge. For Workers KV, write a simple JSON structure the worker can parse in under a millisecond.
// scripts/publish-rules.js — runs in CI after a flag change lands
const rules = {
'web.storefront.new-checkout': {
defaultVariant: 'control',
treatmentVariant: 'beta',
rolloutPct: 20,
},
'web.dashboard.new-nav': {
defaultVariant: 'off',
treatmentVariant: 'on',
rolloutPct: 100,
}
};
await env.FLAG_KV.put('rules', JSON.stringify(rules), {
expirationTtl: 300, // 5-minute max staleness at the edge
});
Pitfall: a long KV TTL delays kill-switch propagation to the edge. Set the KV TTL short enough to meet your incident response SLA, and pair it with a cache purge on flag change.
Keep the KV value small and keep it one blob, not one key per flag. Fetching a single rules object means one KV read per isolate warm-up regardless of how many flags you evaluate; splitting it into per-flag keys multiplies your reads and, on a cold isolate, your tail latency. A rule set of a few hundred flags serialized to JSON is comfortably under the 25 MB KV value ceiling and typically well under 100 KB — small enough that the parse cost is negligible and the whole thing lives in isolate memory after the first read. If the rule set genuinely grows past what you want to ship on every read, that is the signal to bundle a snapshot at deploy time and demote KV to a refresh channel, as the performance section describes.
Step 2 — Build the evaluation context from the request
The edge worker cannot access session state or a database. Build the context from attributes available on the inbound request: Cloudflare geo headers, cookies set by prior origin responses, and internal routing headers.
// workers/context-builder.js
export function buildEvalContext(request) {
return {
targetingKey: request.headers.get('cf-connecting-ip') ?? 'anon',
country: request.cf?.country ?? 'XX',
userTier: getCookieValue(request, 'user_tier') ?? 'free',
internalUser: request.headers.get('X-Internal-User') === '1',
// Add more attributes as cookies or headers allow
};
}
For richer context — plan tier, org ID, experiment cohort — set a signed cookie at login that the edge worker can verify and decode without an origin call. Anything that requires a database lookup must remain at the origin; evaluation context enrichment covers the pattern in detail.
Pitfall: reading PII (email, user ID) from cookies at the edge and embedding it in a cache key leaks identifiers into cache infrastructure. Hash or segment the identifying attribute before using it as a cache key component.
A second cookie-related trap is trusting a client-set value for a privileged variant. If user_tier=staff unlocks an internal build and the cookie is set by the browser rather than signed by your origin, any visitor can forge staff access by editing their own cookie jar. Sign the tier claim — a short HMAC or a compact JWT the worker verifies with a key in an environment binding — so the edge can trust the attribute without an origin call and without granting privileged variants to anyone who reads this guide. The rule is that any attribute which gates a variant a competitor or attacker would want must be authenticated at the edge, not merely read.
Step 3 — Resolve the variant and inject the bootstrap payload
After building the context and resolving the variant, inject a window.__FLAG_BOOTSTRAP__ payload into <head> so the client SDK can initialize synchronously without a network round-trip.
// Combines context building and injection — see full worker above
const ctx = buildEvalContext(request);
const cohort = resolveFromRules(rules, FLAG_KEY, ctx);
const bootstrapScript =
`window.__FLAG_BOOTSTRAP__=${JSON.stringify({ [FLAG_KEY]: cohort })};`;
return new HTMLRewriter()
.on('head', el => el.prepend(`<script>${bootstrapScript}</script>`, { html: true }))
.transform(originResponse);
The client SDK reads window.__FLAG_BOOTSTRAP__ during OpenFeature.setProvider() init — see the backend evaluation series for how the same pattern applies server-side.
Pitfall: HTMLRewriter streams the response body, but the injected <script> must appear before any reference to flags in the page’s own scripts. Using el.prepend on <head> ensures ordering.
Step 4 — Partition the cache key and set a short TTL
Append the resolved cohort to the cache key before storing the response, and set a Cache-Control TTL short enough that a flag flip propagates within your SLA.
// Cache key = URL + cohort; CDN never collapses cohorts
const cacheUrl = new URL(request.url);
cacheUrl.searchParams.set('_cohort', cohort);
const ttl = 60; // seconds — tune to your propagation budget
const response = await fetch(cacheKey);
const headers = new Headers(response.headers);
headers.set('Cache-Control', `public, max-age=${ttl}, s-maxage=${ttl}`);
await cache.put(new Request(cacheUrl.toString()), new Response(response.body, { headers }));
Propagating a Kill Switch to the Edge
A kill switch must reach every edge region, not just the origin. The propagation chain is:
- Control plane updates the flag rule set.
- CI or a webhook writes the new rules to KV (global replication, typically <5 s).
- The KV TTL ensures workers pick up the new rules within the configured window.
- A cache purge API call invalidates all cohort-keyed responses for the affected URL pattern.
# Purge all cohort variants for the storefront checkout page
curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"prefixes":["https://example.com/checkout"]}'
Without the purge step, cached cohort-keyed responses continue serving the old variant until TTL expiry, even though the KV rule set has been updated. Wire the purge into your polling vs streaming flag-change event so it fires automatically on every flag mutation.
Prefer prefix or tag-based purges over enumerating every cohort URL. If you purge only ?_cohort=beta you will leave control and staff copies live, and a kill switch that clears one cohort but not the others is worse than none — it produces a split-brain where some audiences are rolled back and others are not. A prefix purge on the path (or a cache tag stamped on every variant of that page at write time) collapses all cohorts in one call and removes the chance of missing one. Order matters too: update KV before you purge, never after. If you purge first, the window between the purge and the KV propagation refills the cache with freshly evaluated-but-still-old responses, and you have to purge again. Write the new rules, confirm the KV write returned, then purge — that ordering guarantees the refill uses the new variant.
Falling Back to Origin
The edge worker should never become a hard dependency for page delivery. If the rule set is unavailable or the worker throws, fall through to origin with a control variant and let the origin or client SDK handle evaluation.
export default {
async fetch(request, env) {
try {
return await handleWithFlags(request, env);
} catch (err) {
console.error('edge-flag-worker error:', err);
// Fall through — origin serves without bootstrap injection
return fetch(request);
}
}
};
This mirrors the server-side SDK resilience pattern: always have a safe default, and keep the flag path off the critical failure path.
One nuance the naive catch above gets subtly wrong under load: if the KV read times out rather than throwing immediately, the fallback still has to wait for that timeout before fetching origin, so a KV outage adds its full timeout budget to every request. Bound the rule-set read with an explicit deadline — race the KV get against a short AbortController timeout of a few tens of milliseconds — and on timeout fall through to the last snapshot bundled into the worker rather than to a raw origin fetch. That way a degraded KV never becomes a latency amplifier, and the worst case is serving a slightly stale rule set, which is exactly the failure you already tolerate through the TTL. The safe default should be stale-but-fast, not slow-then-origin.
Verification & Testing
After deploying the worker, confirm three things:
- Correct variant in first byte:
curl -sI https://example.com/checkout | grep -i 'cache'andcurl -s https://example.com/checkout | grep __FLAG_BOOTSTRAP__should show the expected cohort. - Cache partitioning works: request the same URL twice with different
user_tiercookies; confirm the response bodies differ and both are served from cache (CF-Cache-Status: HIT). - Kill switch clears: flip the flag, wait for KV propagation, trigger a purge, and re-request — confirm the new cohort appears in the bootstrap payload within your SLA window.
# Confirm bootstrap payload in the first-byte HTML
curl -s https://example.com/ | grep -o '__FLAG_BOOTSTRAP__[^<]*'
# Expected: __FLAG_BOOTSTRAP__={"web.storefront.new-checkout":"beta"}
# Check cache status
curl -sI https://example.com/ | grep -i 'cf-cache-status'
# Expected: CF-Cache-Status: HIT (on repeated request, same cohort)
Troubleshooting & FAQ
Why does every visitor see the same variant despite different targeting attributes?
The cache key is not partitioned by cohort. Every request maps to the same cache entry, so the first cached response is served to all. Add the resolved cohort to the cache key (Step 4) and ensure no upstream Cache-Control: no-vary header is overwriting your intent.
How do I test edge flag behavior without deploying to production?
Use wrangler dev with a bound preview KV namespace containing test rule sets. The local dev server runs the full worker logic against real request fixtures, so you can assert on the injected window.__FLAG_BOOTSTRAP__ payload before promoting to production.
A kill switch flipped but some edge regions still serve the old variant — why?
KV replication and the edge cache TTL are additive. If KV takes 5 s to propagate globally and your cache TTL is 60 s, the worst-case delay is 65 s. Reduce the cache TTL and trigger a cache purge on flag mutation to collapse the window. Verify per-region by hitting PoP-specific URLs or using Cloudflare’s diagnostic headers.
Can I inject flags without HTMLRewriter?
Yes — set a response header (e.g. X-Flag-Variant: beta) and let the page’s <meta> tag or a small inline script read it. This avoids streaming body transformation but requires the page to ship with flag-reading logic. For fully no-JS bootstrapping, header injection is more reliable.
How many cohorts can I safely partition the cache into before hit rate suffers?
Keep it to the number of distinct variant combinations that actually render differently, and multiply carefully. Cache entries grow as the product of every dimension you key on, so two flags with three variants each keyed independently already means nine partitions per URL, and adding geo or device on top multiplies again. Cache storage and eviction are finite, so past a few dozen partitions per URL your hit rate degrades and cold entries get evicted before they are reused. If you need many flags on one page, resolve them into a single composite cohort token — a short hash of the resolved variant tuple — and key on that one value so the partition count tracks distinct rendered outputs rather than the combinatorial space of every flag.
Should the edge worker evaluate flags for authenticated pages, or only public ones?
Edge evaluation shines on cacheable, mostly-anonymous pages — marketing, storefront, docs — where the cache hit rate is what makes it worthwhile. Fully authenticated, per-user pages are usually not cacheable at all, so there is no shared cache to partition and the edge is only saving a round-trip, not a cache lookup. On those pages, consider injecting the bootstrap payload without caching the response (set Cache-Control: private, no-store) so you still eliminate flicker while acknowledging the response was never shareable to begin with. The decision hinges on whether the response is cacheable: if it is, partition it; if it is not, inject but do not cache.
What happens to in-flight requests during a rule-set update?
Nothing abrupt — each worker invocation reads the rule set once at the start and evaluates against that consistent snapshot for the duration of the request, so an update mid-flight cannot produce a half-old, half-new response. The transition is eventual rather than atomic across the fleet: for a short window some isolates hold the old rules and some hold the new, which is why a percentage rollout should never be treated as a synchronized flip. If you need a hard, simultaneous cutover across all edges, gate the change behind a timestamp field in the rule set and have the worker compare against request time, rather than relying on the moment KV happens to propagate.
Performance & Scale Considerations
Edge evaluation adds sub-millisecond overhead per request when the rule set is in KV and the evaluation logic is simple hashing or attribute comparison. The main cost is the first KV read per worker invocation; subsequent reads within the same isolate are in-memory. For very high throughput, bundle a snapshot of the rule set directly into the worker at deploy time and treat KV as a refresh channel rather than the primary read path. Keep the evaluation logic deterministic and allocation-free — avoid JSON.parse on the hot path by pre-parsing rules at isolate startup.
Watch where the real budget goes: on a warm isolate the evaluation itself is noise, but HTMLRewriter walks the entire response body to find the <head> element, so the injection cost scales with document size, not flag count. For large HTML documents this is usually still cheap because the rewriter streams and can flush the head transformation long before the body finishes, but it means you should attach the rewriter only to HTML responses — gate on content-type: text/html and pass images, JSON, and other assets straight through untouched. Running every asset through a body transformer is a common and entirely avoidable waste. Second, remember the percentage-rollout hash must be stable across requests for the same user or the variant will flicker between page loads; hashing a per-request value such as a timestamp or a fresh nonce silently re-buckets the user on every navigation, which looks like a caching bug but is really an unstable targeting key. Anchor the hash on something durable — a signed session cookie or a hashed stable identifier — so a user who lands in beta stays in beta for the life of the rollout. Finally, budget for isolate cold starts under bursty traffic: the first request to a new isolate pays the KV read and parse, so if your traffic is spiky rather than steady, the bundled-snapshot approach pays for itself precisely when you can least afford a slow first byte.