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.

Edge evaluation request sequence A request enters the Cloudflare Worker; the worker reads rules from KV and builds context from the request; resolves the variant; injects a bootstrap script into the HTML response and stores it under a cohort-keyed cache entry. Browser Worker Workers KV Origin 1. Receive request 2. Read rule set (KV) rules JSON 3. Build ctx · resolve variant 4. fetch (cache miss only) HTML response 5. Inject + cache(cohort) personalized HTML
The Worker reads flag rules from KV, builds evaluation context from the request, resolves the variant, and injects a bootstrap script before returning a cohort-keyed cached response.

Prerequisites

Prerequisites for edge evaluation with Workers Four prerequisites: a deployed Worker, a bound KV namespace with the rule set, request-derivable targeting attributes, and cache-purge API access. Deployed Worker on the zone KV namespace FLAG_KV bound Request attributes geo / cookie Purge API token
The bound KV namespace holds the rule set the Worker reads locally; purge access is what makes a flag flip visible before TTL expiry.

Step-by-Step Procedure

Four steps run entirely inside the Worker: load and cache the rules from KV, build the context from the request, resolve and inject the variant, and partition the cache by cohort so responses stay cacheable.

The four in-Worker steps Load the rule set from KV into module state, build the evaluation context from the request, resolve the variant and inject the bootstrap, and partition the cache key by cohort. 1 · Load rules KV → module cache 2 · Build ctx from request 3 · Resolve + inject HTMLRewriter 4 · Partition cache cohort in key
Step 4 is the one teams forget: without the cohort in the cache key, the first variant is served to everyone.

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"}
Four verification checks for the edge worker Confirm the bootstrap payload is present, a staff header yields the treatment variant, repeated same-cohort requests hit cache, and a KV update plus purge flips the served variant. payload present control staff header beta repeat request cache HIT KV + purge off
The four checks cover correctness (right variant), targeting (staff override), caching (HIT), and the kill switch (flip to off).

Gotchas & Edge Cases

Cold-start KV read versus a bundled snapshot The first request to a fresh isolate pays a live KV read of one to five milliseconds; bundling the rule set at deploy time makes every request in-memory, with KV used only for hot reload. cold isolate first request reads KV 1–5 ms once bundled snapshot rules imported at deploy in-memory every request
Module-level caching absorbs the cold-start KV read for most workloads; bundle the snapshot only when you need deterministic sub-millisecond evaluation on the very first request too.

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.