All ArticlesLanding Page Examples•November 4, 2026•11 min read

Landing Page Personalization at 50M Sessions: Edge Compute vs CDN Rules vs Client-Side Targeting

At 50M sessions a month, the topology you choose for personalization — CDN rules, edge middleware, or client-side targeting — decides your TTFB, your LCP, whether users see a flash of the wrong hero, and your true cost per million conversions. This is the architecture trade-off analysis, with production code and a defensible cost model.

Landing Page Personalization at 50M Sessions: Edge Compute vs CDN Rules vs Client-Side TargetingAI Generated with Qwen 3.0 via CometAPI

Most teams discover the real cost of landing page personalization the week their CDN cache hit ratio drops from 94% to 41% — because the experimentation platform started returning a different variant for the same URL to different users. At 50M sessions per month, that single architectural decision determines whether you ship Core Web Vitals–compliant personalization or a TTFB regression that quietly taxes every conversion you own.

There are exactly three places to resolve a variant: in the CDN's rule layer, in edge compute, or in the browser. They are not interchangeable. They have different latency profiles, different caching semantics, different failure modes, and — critically — different costs once you amortize platform fees and engineering over a million conversions.

The Three Topologies, Precisely Defined

Topology 1 — CDN Rules (declarative, cache layer)

Request-time logic evaluated inside the CDN's rule engine: Cloudflare Rules / Cache Rules / Transform Rules, Fastly VCL, CloudFront Functions, Akamai Property Manager. Execution happens before or around cache lookup, in the same process as the cache. Constraints are severe: no external I/O, no persistent state, an expression language that is not Turing-complete. In exchange you get sub-millisecond execution with no cold start and no per-request billing beyond your CDN contract.

Topology 2 — Edge Compute (Turing-complete, request layer)

Cloudflare Workers, Lambda@Edge, Vercel Edge Middleware, Deno Deploy. A real runtime (JS/WASM/Rust) that can read a KV store, decode a signed flag payload, rewrite HTML with a streaming transform, and set cookies — all before the first byte reaches the client. Cost: a CPU budget per invocation (Workers defaults to 50ms CPU on paid plans; you can raise it), possible subrequest latency, and cold-isolate P99 spikes.

Topology 3 — Client-Side Targeting (post-paint, DOM layer)

Variant resolved in the browser after HTML arrives. Zero TTFB impact, trivially cheap at the compute layer, and the only topology that can read localStorage, viewport, or scroll depth. It is also the only topology that guarantees a second paint — which means a flash of incorrect content (FOIC) if you personalize anything above the fold.

TTFB: What Each Topology Actually Adds

TopologyP50 Δ TTFBP99 Δ TTFBCache hit ratio impact
CDN rules (header/geo/cookie match)+1–5 ms+5–15 ms0% if the variant is folded into the cache key
Edge compute, cache-first (no subrequest)+3–12 ms+25–60 ms (cold isolate)0% with key normalization
Edge compute + KV/flag fetch + origin+15–45 ms+90–250 msn/a — dynamic by design
Client-side targeting+0 ms+0 ms0%

Two things matter here. First, the naive anti-pattern — adding Vary: Cookie to a personalized HTML response — does not add TTFB, it destroys your cache. Every unique cookie becomes a unique cache object. At 50M sessions you will miss on nearly every request and your origin will absorb the full load.

Second, TTFB regressions are not free. Deloitte's 2017 Milliseconds Make Millions study measured a 0.1s mobile load-time improvement translating into +8.4% retail conversions and +9.2% for travel. A 200ms edge-compute penalty that buys you a 4% lift is a net loss.

LCP and the Flicker Problem

The LCP element on a landing page is almost always the hero image or the H1. That is precisely the element personalization wants to change — and precisely the element you must not mutate after paint.

When a client-side segment swap fires after hydration, three things happen simultaneously: LCP is deferred by the hydration delay, CLS spikes as the swapped content reflows, and the user sees the control headline flash before the variant. That flash is measurable — and it correlates with bounce in every RUM dataset we've seen.

Three mitigations, in order of preference:

  1. Resolve the variant server-side (CDN rule or edge). Flicker is architecturally impossible because there is only one paint.
  2. If client-side is mandatory, hide only the personalized region with visibility: hidden plus a hard reveal timeout, and reserve the exact final box with aspect-ratio so CLS stays at zero.
  3. Never client-side the LCP element. Personalize below the fold, or use an opacity crossfade on non-LCP content.

Implementation: Edge Middleware That Doesn't Break Your Cache

The critical production detail is cache key normalization: the variant becomes part of the URL the origin and cache see, so each variant is cached exactly once instead of poisoning a shared entry.

ts
interface Env {
  FLAGS: KVNamespace;
  EXPOSURE_QUEUE: Queue<ExposureEvent>;
}

type Variant = 'control' | 'variant_a' | 'variant_b';

interface Assignment {
  experimentId: string;
  variant: Variant;
  bucket: number;
}

interface ExposureEvent extends Assignment {
  visitorId: string;
  path: string;
  ts: number;
}

const EXPERIMENT_ID = 'lp-hero-q3';
const TRAFFIC_ALLOCATION = 0.9; // 90% of sessions enter the experiment

/** FNV-1a 32-bit. Deterministic across edge, origin, and analytics. */
function fnv1a(input: string): number {
  let hash = 0x811c9dc5;
  for (let i = 0; i < input.length; i++) {
    hash ^= input.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash >>> 0;
}

function assign(visitorId: string): Assignment | null {
  const bucket = fnv1a(`${EXPERIMENT_ID}:${visitorId}`) % 10_000;
  if (bucket >= TRAFFIC_ALLOCATION * 10_000) return null;
  const variant: Variant = bucket % 2 === 0 ? 'variant_a' : 'variant_b';
  return { experimentId: EXPERIMENT_ID, variant, bucket };
}

function readVisitorId(request: Request): string {
  const cookie = request.headers.get('cookie') ?? '';
  const match = cookie.match(/(?:^|;\s*)_vid=([A-Za-z0-9-]{8,64})/);
  return match ? match[1] : crypto.randomUUID();
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // Only personalize HTML navigations. Never API, asset, or beacon traffic.
    if (
      request.method !== 'GET' ||
      !(request.headers.get('accept') ?? '').includes('text/html')
    ) {
      return fetch(request);
    }

    const visitorId = readVisitorId(request);
    const assignment = assign(visitorId);
    const variant: Variant = assignment?.variant ?? 'control';

    const url = new URL(request.url);
    url.searchParams.set('_v', variant); // <-- cache key normalization

    const headers = new Headers(request.headers);
    headers.set('x-ab-variant', variant);
    headers.delete('cookie'); // keep the origin response cacheable

    let upstream: Response;
    try {
      upstream = await fetch(new Request(url.toString(), { ...request, headers }));
    } catch (err) {
      // Fail open to control. Personalization must never break the page.
      console.error('origin_fetch_failed', { message: (err as Error).message });
      return new Response('Service temporarily unavailable', {
        status: 503,
        headers: { 'retry-after': '2' },
      });
    }

    // Exposures go to a Queue — never KV. KV writes are billed per operation
    // and rate-limited per key; a 50M-session workload will blow through it.
    if (assignment) {
      ctx.waitUntil(
        env.EXPOSURE_QUEUE.send({ ...assignment, visitorId, path: url.pathname, ts: Date.now() })
          .catch((err: unknown) =>
            console.warn('exposure_enqueue_failed', { message: (err as Error).message })
          )
      );
    }

    const response = new Response(upstream.body, upstream);
    response.headers.append(
      'set-cookie',
      `_vid=${visitorId}; Path=/; Max-Age=31536000; SameSite=Lax; Secure`
    );
    response.headers.set('x-ab-variant', variant);
    return response;
  },
} satisfies ExportedHandler<Env>;

Note the satisfies ExportedHandler<Env> — it type-checks the handler shape at compile time without widening the inferred types, which matters when you have several Workers sharing a typing package.

Implementation: CDN Rules Without Cache Poisoning

VCL is the most expressive of the declarative rule engines, so it makes the best reference implementation. The pattern generalizes to Cloudflare Cache Rules with Cache Key customization and to CloudFront Functions.

vcl
sub vcl_recv {
  # 1. Derive a stable bucket from a first-party cookie, fall back to client IP.
  if (req.http.Cookie:_vid) {
    set req.http.X-Bucket = std.digest.crc32(req.http.Cookie:_vid ":lp-hero-q3");
  } elsif (req.http.Fastly-Client-IP) {
    set req.http.X-Bucket = std.digest.crc32(req.http.Fastly-Client-IP ":lp-hero-q3");
  } else {
    set req.http.X-Bucket = "0";
  }

  # 2. 80% in-experiment, 50/50 split. Everyone else gets control.
  if (std.atoi(req.http.X-Bucket) % 100 < 80) {
    if (std.atoi(req.http.X-Bucket) % 2 == 0) {
      set req.http.X-Variant = "variant_a";
    } else {
      set req.http.X-Variant = "control";
    }
  } else {
    set req.http.X-Variant = "control";
  }

  # 3. CRITICAL: fold the variant into the cache key. Without this, the first
  #    variant to reach a PoP is served to every subsequent visitor.
  set req.url = querystring.add(req.url, "_v=" + req.http.X-Variant);

  # 4. Strip cookies so the origin response remains cacheable.
  unset req.http.Cookie;
}

sub vcl_deliver {
  set resp.http.X-AB-Variant = req.http.X-Variant;
  set resp.http.Vary = "Accept-Encoding";   # never "Cookie"
}

The failure mode to internalize: forgetting step 3. The rule sets a variant header, the origin renders a variant, and the CDN caches whichever one arrived first at that PoP — served to every user in that region for the full TTL. It is a silent, regional, hard-to-reproduce correctness bug that shows up as inexplicably flat experiment results.

Implementation: Client-Side Targeting With Flicker Suppression

If you must resolve in the browser, constrain the blast radius to a single region and reserve its layout box.

html
<style>
  /* Hide only the personalized region, and reserve the final box to protect CLS. */
  [data-ab="pending"] { visibility: hidden; }
  [data-ab="pending"] .ab-slot { aspect-ratio: 16 / 9; display: block; }
</style>

<script>
(function () {
  var root = document.querySelector('[data-ab]');
  if (!root) return;

  // Failsafe: reveal control content after 250ms no matter what.
  var timer = setTimeout(function () { root.setAttribute('data-ab', 'control'); }, 250);

  function apply(variant) {
    clearTimeout(timer);
    try {
      root.setAttribute('data-ab', variant);
      var slot = root.querySelector('[data-ab-slot="' + variant + '"]');
      if (slot) slot.hidden = false;
    } catch (e) {
      root.setAttribute('data-ab', 'control'); // fail open
    }
  }

  // Read the variant the edge already decided. Never make a synchronous
  // network call above the fold — that is the definition of a TTFB regression.
  var m = document.cookie.match(/(?:^|;\s*)ab_variant=(control|variant_a|variant_b)/);
  apply(m ? m[1] : 'control');
})();
</script>

Instrumenting LCP With Variant Attribution

You cannot optimize what you cannot segment. Attach the variant to every LCP sample so regressions are attributable to a specific experience.

ts
interface LcpSample {
  value: number;
  element: string | null;
  url: string | null;
  variant: string;
  path: string;
}

function reportLcp(sample: LcpSample): void {
  const body = JSON.stringify(sample);
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/rum/lcp', new Blob([body], { type: 'application/json' }));
    return;
  }
  fetch('/rum/lcp', { method: 'POST', body, keepalive: true }).catch(() => {
    /* RUM loss is acceptable; never surface it to the user */
  });
}

new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const last = entries[entries.length - 1] as PerformanceEntry & {
    element?: Element;
    url?: string;
  };
  reportLcp({
    value: Math.round(last.startTime),
    element: last.element?.tagName ?? null,
    url: last.url ?? null,
    variant: document.documentElement.dataset.ab ?? 'unknown',
    path: location.pathname,
  });
}).observe({ type: 'largest-contentful-paint', buffered: true });

The True Cost Per Million Conversions

Model assumptions: 50M sessions/month × 1.4 document requests = 70M personalized document requests. Baseline conversion rate 3.0% → 1.5M conversions/month. Loaded engineering cost $16k/mo per FTE.

TopologyInfra/moPlatform/moPeople/moTotal/moCost per 1M existing conversionsRelative liftIncremental conversionsCost per incremental conversion
CDN rules~$0 (bundled)$0$4,000 (0.25 FTE)$4,000$2,667+3–5%45k–75k$0.05–$0.09
Edge compute~$60–$140$500–$3,000$12,000 (0.75 FTE)$12,600–$15,100$8,400–$10,100+7–12%105k–180k$0.07–$0.14
Client-side~$0$1,500–$8,000$8,000 (0.5 FTE)$9,500–$16,000$6,300–$10,700+5–9%, minus 1.5–3% for LCP regression55k–120k$0.08–$0.29

Three conclusions fall out of the model:

  1. Compute is a rounding error. At 70M requests, Cloudflare Workers costs roughly $23 in requests plus ~$35 in KV reads — under 1.5% of the total program cost. The rest is platform licensing and people. Anyone optimizing the per-request line item is optimizing the wrong variable.
  2. "Cost per million conversions" is a misleading metric on its own. It rewards doing nothing, because a $0 program has a $0 cost per million conversions and also produces zero incremental conversions. The metric that governs the decision is cost per incremental conversion — and there the three topologies land within ~5× of each other while differing by 2.4× in lift.
  3. The LCP tax is real. A 200ms LCP regression on a client-side experience consumes roughly a quarter to a third of the lift it produces. That is the hidden cost line missing from most vendor comparisons.

Decision Matrix

RequirementCDN rulesEdge computeClient-side
Zero flicker guaranteed✅✅❌
Geo / device / referrer segments✅✅✅
Behavioral segments (session depth, scroll)❌⚠️ via cookie✅
HTML rewriting (hero copy, pricing)⚠️ limited✅✅
Server-side A/B with sticky assignment⚠️✅⚠️
Sub-15ms P50 TTFB delta✅✅✅
No cold-start P99 risk✅⚠️✅
Cache hit ratio preserved✅ with key normalization✅ with key normalization✅
Operating cost at 50M sessionsLowestHighestMid

Default recommendation for a 50M-session landing page estate: push segment resolution into CDN rules wherever the segment is expressible (geo, device class, campaign utm_*, authenticated cookie). Escalate to edge compute only when you need a decision the rule language cannot make — sticky multi-experiment assignment, mutual exclusion across tests, or HTML rewriting of the hero. Reserve client-side for below-the-fold modules where behavioral state genuinely matters.

Operational Checklist Before You Ship

  • Variant is folded into the cache key (query param or custom cache key) — verify with curl -I against two distinct cookies and confirm distinct cf-cache-status / x-cache values.
  • Vary header does not include Cookie on the HTML document.
  • Bucketing hash is identical in edge code, origin fallback, and analytics — a mismatch silently invalidates the experiment.
  • Failure path is fail open to control, never a 5xx.
  • Exposure events go to a queue or analytics pipeline, not a synchronous write path.
  • RUM captures LCP (and INP) segmented by variant.
  • P99 TTFB is monitored per variant, not just globally — a cold-isolate regression hides in the average.
  • Kill switch exists: a single flag flip reverts 100% of traffic to control within one TTL.
  • Sequential testing or CUPED variance reduction is applied before declaring a winner (see Kohavi et al.).

Sources & Technical References

3 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles