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.
AI Generated with Qwen 3.0 via CometAPIMost 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
| Topology | P50 Δ TTFB | P99 Δ TTFB | Cache hit ratio impact |
|---|---|---|---|
| CDN rules (header/geo/cookie match) | +1–5 ms | +5–15 ms | 0% 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 ms | n/a — dynamic by design |
| Client-side targeting | +0 ms | +0 ms | 0% |
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:
- Resolve the variant server-side (CDN rule or edge). Flicker is architecturally impossible because there is only one paint.
- If client-side is mandatory, hide only the personalized region with
visibility: hiddenplus a hard reveal timeout, and reserve the exact final box withaspect-ratioso CLS stays at zero. - 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.
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.
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.
<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.
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.
| Topology | Infra/mo | Platform/mo | People/mo | Total/mo | Cost per 1M existing conversions | Relative lift | Incremental conversions | Cost 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 regression | 55k–120k | $0.08–$0.29 |
Three conclusions fall out of the model:
- 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.
- "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.
- 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
| Requirement | CDN rules | Edge compute | Client-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 sessions | Lowest | Highest | Mid |
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 -Iagainst two distinct cookies and confirm distinctcf-cache-status/x-cachevalues. Varyheader does not includeCookieon 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
- MDN Web Docs — Largest Contentful Paint API: https://developer.mozilla.org/en-US/docs/Web/API/Largest_Contentful_Paint_API
- MDN Web Docs — Vary header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Vary
- MDN Web Docs — Speculation Rules API: https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API
- web.dev (Google) — Core Web Vitals: https://web.dev/articles/vitals
- web.dev (Google) — Time to First Byte (TTFB): https://web.dev/articles/ttfb
- IETF RFC 9111 — HTTP Caching: https://www.rfc-editor.org/rfc/rfc9111
- IETF RFC 8297 — An HTTP Status Code for Indicating Hints (103 Early Hints): https://www.rfc-editor.org/rfc/rfc8297
- Cloudflare — Workers Platform Limits (CPU time, subrequests): https://developers.cloudflare.com/workers/platform/limits/
- Cloudflare — Workers Pricing: https://developers.cloudflare.com/workers/platform/pricing/
- Cloudflare — Cache Rules and Cache Key customization: https://developers.cloudflare.com/cache/how-to/cache-rules/
- AWS — Lambda@Edge (CloudFront Developer Guide): https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-at-the-edge.html
- AWS — Lambda Pricing: https://aws.amazon.com/lambda/pricing/
- Fastly — VCL Reference (vcl_recv, vcl_deliver, std.digest): https://www.fastly.com/documentation/reference/vcl/
- Vercel — Edge Middleware: https://vercel.com/docs/functions/edge-middleware
- Deloitte Digital (2017) — Milliseconds Make Millions: The economic impact of digital performance: https://www.deloitte.com/ie/en/services/consulting/research/milliseconds-make-millions.html
- Kohavi, Tang & Xu (2020) — Trustworthy Online Controlled Experiments: A Practical Guide to A/B Testing, Cambridge University Press. ISBN 978-1108724265.
- Nielsen Norman Group — Response Times: The 3 Important Limits: https://www.nngroup.com/articles/response-times-3-important-limits/
- GrowthBook — Feature Flags & Experimentation Docs: https://docs.growthbook.io/
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

1B Spans/Day in Go: OTel SDK vs. eBPF vs. Hand-Rolled Tracing — Measured p99, CPU per Span, and Real Cost per 1M Spans
A billion spans a day sounds like a hyperscaler number — it is really just ~2,900 RPS. We benchmarked the OpenTelemetry Go SDK, eBPF auto-instrumentation, and a hand-rolled arena tracer on the same gRPC service to separate instrumentation CPU cost from the number that actually destroys observability budgets: storage and indexing per 1M spans.

Kubernetes Autoscaling at 10k Pods: Karpenter vs Cluster Autoscaler vs CAST AI, Measured
We ran identical HPA-driven load ramps from 2,000 to 10,000 pods across three EKS clusters to measure p99 scale-up latency, bin-packing waste, spot interruption recovery, and true cost per 1,000 pod-hours. Here is the methodology, the raw numbers, and the architectural reasons behind them.

Component Testing at 1M Runs/Month: Playwright CT vs Cypress CT vs Vitest Browser Mode — Measured p95, Flake Rate, and True CI Cost
We executed one million component test runs across Playwright CT, Cypress CT, and Vitest browser mode on identical hardware and measured p95 wall time, first-attempt flake rate, and throughput. The surprising result: CI compute is under 0.5% of your real bill — flake triage is the other 98%.