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.

Edge flag delivery topology A browser request hits a CDN edge worker that evaluates flags and injects the bootstrap payload, using cohort-keyed cache partitions; cache misses fall through to origin. Browser request CDN Edge Worker Evaluate flags geo · cookie · header Cache partitions (by cohort) beta control staff Vary header per resolved variant Flag rule set KV / bundled Origin cache miss only miss
The edge worker resolves the variant from a local rule set, injects the bootstrap payload, and serves from a cohort-keyed cache partition; only cache misses reach origin.

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.

Client fetch round-trip versus edge injection Client-side init loads the page, then fetches flags and flashes the default; edge injection resolves the variant in the worker so the first byte already carries the correct bootstrap. Client fetch page loads fetch flags → flash default Edge injection worker resolves + injects first byte has correct variant
The worker that already terminates the request resolves the flag inline, so the variant ships in the first byte instead of after a client round-trip.

What edge delivery does not cover:

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

Prerequisites for edge flag delivery Four prerequisites: a CDN with edge workers, the rule set accessible at the edge, a cache strategy with per-cohort keys, and request-derivable targeting attributes. Edge workers Workers / Compute Rule set at edge KV or bundled Per-cohort cache Vary / custom key Request attributes geo / cookie / header
The per-cohort cache key is the make-or-break prerequisite — without it the edge collapses every audience onto the first cached variant.

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.

Three patterns for getting a variant into the response Edge evaluation resolves locally with sub-millisecond latency; origin injection resolves server-side but pays origin latency on every miss; client bootstrap ships plain HTML and pays a client round-trip. Edge evaluation worker reads local rules sub-ms, no origin call partition cache by cohort Origin injection origin resolves, sets header origin latency per miss edge must respect Vary Client bootstrap browser SDK fetches extra round-trip no variant in first byte
Edge evaluation wins on time-to-first-variant; the trade-off is replicating the rule set to every region, which a hybrid fallback to origin softens.

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:

  1. Control plane updates the flag rule set.
  2. CI or a webhook writes the new rules to KV (global replication, typically <5 s).
  3. The KV TTL ensures workers pick up the new rules within the configured window.
  4. 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.

Kill-switch propagation chain to the edge The control plane updates the rule set, CI writes it to KV with global replication, the KV TTL forces workers to refresh, and a cache purge invalidates every cohort-keyed response. control plane rule updated write to KV global < 5 s TTL refresh workers pick up cache purge all cohorts skip the purge and cohort-keyed responses serve the old variant until TTL expiry
The KV write refreshes the rules, but only the cache purge clears already-cached cohort responses — both are required for a fast edge kill switch.

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.

The worker must never be a hard dependency If the rule set is missing or the worker throws, it falls through to origin with a control variant so page delivery never depends on the flag path succeeding. worker ok? rules present yes → resolve + inject bootstrap variant in first byte no / throw → fetch origin, control page still delivered
A thrown worker or missing rule set falls through to origin with the control variant, so the flag path is never on the page's critical failure path.

Verification & Testing

After deploying the worker, confirm three things:

  1. Correct variant in first byte: curl -sI https://example.com/checkout | grep -i 'cache' and curl -s https://example.com/checkout | grep __FLAG_BOOTSTRAP__ should show the expected cohort.
  2. Cache partitioning works: request the same URL twice with different user_tier cookies; confirm the response bodies differ and both are served from cache (CF-Cache-Status: HIT).
  3. 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)
Three things to confirm after deploying the worker The correct variant appears in the first byte, two different cohorts get distinct cached responses, and a flag flip clears through KV and cache purge within the SLA. first byte correct cohort in __FLAG_BOOTSTRAP__ partitioning cohorts differ both cache HIT kill switch clears in SLA KV + purge
Confirm the variant reaches the first byte, that partitioning keeps cohorts distinct, and that a flip clears within the propagation budget.

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.

Bundle the rules for the hot path, use KV as a refresh channel The first KV read per isolate is the main cost; bundling a rule-set snapshot into the worker and pre-parsing at startup keeps the per-request path in-memory and allocation-free. KV read on hot path first read + JSON.parse the main cost bundle + pre-parse rules in-memory at startup KV = refresh channel
Treat KV as the refresh channel, not the per-request read path: bundle and pre-parse the rules so evaluation stays in-memory and allocation-free.