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.
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.
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.
Prerequisites
Varyor custom cache keys per audience segment
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.
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.
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.
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.
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.
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.
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.
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.