Evaluating Flags at the CDN Edge with Workers
This how-to is part of Edge & CDN Flag Delivery. It covers the specific mechanics of running flag evaluation inside a Cloudflare Worker so that the very first HTTP response the browser receives already contains the correct variant — no client-side round-trip, no UI flicker on first paint.
The problem: without edge evaluation, the browser loads the HTML, downloads the SDK, fetches flags from the control plane, and only then renders the correct variant. Each step adds latency and produces a visible flash of the default state. Moving evaluation to the CDN edge collapses all of that into a single response: the Worker intercepts the request, reads the rule set it already holds locally, resolves the variant, and injects the result before forwarding the response to the browser.
Prerequisites
FLAG_KV) with flag rule set pre-populatedwranglerCLI ≥ 3.x installed and authenticatedCache Purgepermission)
Step-by-Step Procedure
Step 1 — Load the flag rule set into the Worker from KV with a short refresh
The Worker reads the rule set from Workers KV on each cold start or after the KV TTL lapses. Parse and cache the rules in module-level state so they survive across requests in the same isolate — KV reads add 1–5 ms and should not happen on every request.
// workers/edge-flags.js
// wrangler.toml: kv_namespaces = [{ binding = "FLAG_KV", id = "..." }]
let cachedRules = null;
let cacheExpiry = 0;
const RULES_TTL_MS = 30_000; // refresh rules every 30 s
async function getRules(env) {
if (cachedRules && Date.now() < cacheExpiry) return cachedRules;
const raw = await env.FLAG_KV.get('rules', { type: 'json' });
cachedRules = raw ?? {};
cacheExpiry = Date.now() + RULES_TTL_MS;
return cachedRules;
}
Pitfall: if the KV TTL on the stored value is longer than your kill-switch propagation SLA, a forced flag flip won’t reach running isolates until the module-level cache expires. Keep RULES_TTL_MS at or below the KV value TTL you set when writing rules.
Step 2 — Build the evaluation context from the request
Derive every targeting attribute from information on the inbound request. Geo data arrives via Cloudflare’s cf object; user tier and cohort arrive via cookies or headers set at login. Do not make an origin call to fetch context — that defeats the purpose of edge evaluation.
// workers/edge-flags.js (continued)
function buildContext(request) {
const country = request.cf?.country ?? 'XX';
const userTier = getCookie(request, 'user_tier') ?? 'free';
const ip = request.headers.get('CF-Connecting-IP') ?? '0.0.0.0';
// X-Internal-User is set by Cloudflare Access for authenticated staff
const isStaff = request.headers.get('X-Internal-User') === '1';
return { country, userTier, ip, isStaff };
}
function getCookie(request, name) {
const header = request.headers.get('Cookie') ?? '';
const m = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
return m ? decodeURIComponent(m[1]) : null;
}
For attributes that require server state — plan tier from a database, org membership from an auth service — set a short-lived signed cookie at your origin login handler and verify its HMAC at the edge. This keeps the edge evaluation self-contained. See evaluation context enrichment for the full context assembly pattern.
Pitfall: do not use raw PII (email address, user ID) as a cache key component. Hash the identifier or use an opaque cohort label derived from it. The cohort label beta or control is safe; a user ID embedded in a cache URL is not.
Step 3 — Resolve the variant and inject a bootstrap payload into the response
Apply the targeting rules against the context and inject the result into <head> using HTMLRewriter. The client SDK reads window.__FLAG_BOOTSTRAP__ during OpenFeature.setProvider() and skips the network fetch when a cached value is present.
// workers/edge-flags.js (continued)
// Flag key follows namespace.service.feature convention
const FLAG_KEY = 'web.storefront.new-checkout';
function resolveVariant(rules, flagKey, ctx) {
const rule = rules[flagKey];
if (!rule) return 'control';
// Explicit overrides first
if (ctx.isStaff) return rule.treatmentVariant ?? 'beta';
if (ctx.userTier === 'enterprise') return rule.treatmentVariant ?? 'beta';
// Percentage rollout — stable hash on IP + flagKey
const bucket = stableHash(`${flagKey}:${ctx.ip}`) % 100;
if (rule.rolloutPct != null && bucket < rule.rolloutPct) {
return rule.treatmentVariant ?? 'beta';
}
return rule.defaultVariant ?? 'control';
}
function injectBootstrap(response, flagKey, variant) {
const payload = JSON.stringify({ [flagKey]: variant });
return new HTMLRewriter()
.on('head', {
element(el) {
// prepend so it runs before any framework bootstrap
el.prepend(`<script>window.__FLAG_BOOTSTRAP__=${payload};</script>`, { html: true });
}
})
.transform(response);
}
function stableHash(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h;
}
Pitfall: HTMLRewriter operates on streaming HTML. If the page ships a Content-Security-Policy header that requires nonce values on inline scripts, the injected <script> tag will be blocked by the browser unless the edge worker also injects the matching nonce. Coordinate with your CSP boundaries guide to handle this — either omit script-src 'nonce-*' for the bootstrap script URL pattern or have the worker generate and forward the nonce.
Step 4 — Partition the cache key by resolved cohort to stay cacheable
Append the cohort label to the request URL before storing in caches.default. This keeps each variant’s response in a separate cache bucket so a beta response never overwrites a control response at the same URL.
// workers/edge-flags.js — main fetch handler
export default {
async fetch(request, env) {
try {
const rules = await getRules(env);
const ctx = buildContext(request);
const variant = resolveVariant(rules, FLAG_KEY, ctx);
// Build a cohort-keyed cache URL
const cacheUrl = new URL(request.url);
cacheUrl.searchParams.set('_cohort', variant);
const cacheRequest = new Request(cacheUrl.toString(), { method: 'GET' });
const cache = caches.default;
let response = await cache.match(cacheRequest);
if (!response) {
// Cache miss: fetch origin, inject, store under cohort key
const originResp = await fetch(request);
response = injectBootstrap(originResp, FLAG_KEY, variant);
if (response.status === 200) {
const headers = new Headers(response.headers);
headers.set('Cache-Control', 'public, max-age=60, s-maxage=60');
response = new Response(response.body, { status: 200, headers });
await cache.put(cacheRequest, response.clone());
}
}
return response;
} catch (err) {
// Fallback: serve plain origin response; never fail the page
console.error('[edge-flags] worker error:', err.message);
return fetch(request);
}
}
};
Pitfall: the default caches.default cache in Workers is shared across all requests for the same Cloudflare zone. If you forget to append the cohort to the cache URL, the first cached variant wins for all users regardless of their targeting attributes — a classic cache-poisoning scenario. Always verify the partition is in place before enabling in production.
Verification Step
Confirm the worker is injecting the correct variant and that the cache is partitioned.
# 1. Check bootstrap payload is present in the first-byte HTML
curl -s https://example.com/ \
-H "Cookie: user_tier=free" \
| grep -o 'window\.__FLAG_BOOTSTRAP__=[^<]*'
# Expected: window.__FLAG_BOOTSTRAP__={"web.storefront.new-checkout":"control"}
# 2. Request with staff header should get treatment variant
curl -s https://example.com/ \
-H "Cookie: user_tier=free" \
-H "X-Internal-User: 1" \
| grep -o '__FLAG_BOOTSTRAP__[^<]*'
# Expected: __FLAG_BOOTSTRAP__={"web.storefront.new-checkout":"beta"}
# 3. Cache is partitioned — HIT for repeated same-cohort request
curl -sI https://example.com/ -H "Cookie: user_tier=free" | grep -i cf-cache-status
# Expected: CF-Cache-Status: HIT (on second request)
# 4. Kill-switch: update KV rules, wait ~30 s, purge cache, re-request
wrangler kv:key put --binding FLAG_KV rules '{"web.storefront.new-checkout":{"defaultVariant":"off","rolloutPct":0}}'
sleep 35
curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_TOKEN}" \
-d '{"prefixes":["https://example.com/"]}'
curl -s https://example.com/ | grep '__FLAG_BOOTSTRAP__'
# Expected: __FLAG_BOOTSTRAP__={"web.storefront.new-checkout":"off"}
Gotchas & Edge Cases
- Cold-start KV latency: the first request to a new Worker isolate performs a live KV read (1–5 ms). Subsequent requests within the same isolate hit the module-level cache. This is acceptable for most workloads, but if you need deterministic sub-millisecond evaluation on every request, bundle the rule set as a JSON import at deploy time and use KV only for hot-reload.
- Isolate recycling: Cloudflare may recycle isolates after a period of inactivity, resetting
cachedRules. SizeRULES_TTL_MSto a value you’re comfortable serving as the maximum staleness window, not just the target. - Cohort label leaks in URLs: the
_cohortquery parameter appended for cache keying will appear in server access logs and browser history for cache-miss requests that get redirected. If the cohort label is sensitive (e.g.internal-staff), use an opaque hash instead of a human-readable label.
Troubleshooting & FAQ
The bootstrap payload is missing from some responses but present on others
The worker is only catching a subset of requests. Verify the worker route in wrangler.toml covers the full URL pattern, and confirm that cached responses (CF-Cache-Status: HIT) were stored with the injected script. A HIT response from before the worker was deployed will serve without the payload until TTL expiry — purge the cache after the first worker deploy.
Variant resolution is inconsistent for the same user across page loads
The stable hash function is keyed on CF-Connecting-IP. If the user is behind a NAT or VPN that rotates IP addresses, the bucket changes. Use a stable, request-invariant identifier instead — a deterministic user-tier cookie or an opaque session token set at login. See secure browser delivery for setting tamper-resistant cookies from the origin.
How do I keep the edge evaluation in sync with backend evaluation for server-rendered pages?
The edge worker and the backend evaluation layer must read from the same rule set. Write flag rules to both Workers KV and your server-side cache from the same CI job or webhook handler. If the rule sets diverge, a user whose variant was set at the edge by the worker may receive a different variant from the SSR origin on the next navigation, causing a hydration mismatch.