Stop Faking Personalisation: Edge Rewrites Instead of Client-Side Flicker
Client-side A/B frameworks write their assignment cookie with document.cookie — which Safari caps at seven days, silently re-bucketing returning visitors and corrupting any test that runs longer than a week. That, plus the layout shift. Both fix at the edge.
Stop Faking Personalisation: Edge Rewrites Instead of Client-Side Flicker
Disclosure: We implement edge personalisation for clients, and we use Cloudflare Workers on our own infrastructure. Code samples below are written against Cloudflare and Vercel because those are the platforms we run; the pattern is not specific to either.
Two defects, and the second is worse than the one everyone writes about.
The visible one: client-side personalisation makes its decision after the browser has already painted the wrong answer. You get layout shift and a flash of the original content.
The one nobody mentions: client-side A/B frameworks store the variant assignment by writing a cookie with document.cookie. Safari’s Intelligent Tracking Prevention caps script-written cookies at seven days. So every returning Safari visitor who takes longer than a week to come back gets re-bucketed into a different variant — silently, with no error, and with the resulting contamination showing up nowhere in your experiment dashboard.
If you have run a test for longer than a week and reported a result, that result includes an unknown number of visitors who saw both variants and were counted as two people. The flicker is a UX problem. This is a data-integrity problem, and it invalidates conclusions rather than merely annoying users.
Both have the same fix. Start with the visible one, because it explains the architecture.
Every client-side A/B tool, every geo-pricing script, every “show enterprise CTA to enterprise visitors” snippet works the same way. HTML arrives. Browser paints it. Script runs. Script mutates the DOM. Browser repaints. The user sees the change happen.
That visible change is a layout shift, it counts against Cumulative Layout Shift, and it looks like a bug because it is one.
The anti-flicker snippet is a confession
The standard mitigation makes the problem explicit. Client-side testing tools ship something like this:
<!-- The industry's admission that this architecture is broken -->
<style>body { opacity: 0 !important; }</style>
<script>
setTimeout(function () {
document.body.style.opacity = '1';
}, 4000); // "safety" timeout
</script>
Read what that does. It hides the entire page until the experiment framework has loaded and applied its changes, with a multi-second timeout in case the script fails.
You have deliberately blanked the page to conceal a rendering artifact. Largest Contentful Paint now cannot fire before the experiment script resolves, because nothing is visible. You have converted a layout-stability problem into a loading-performance problem and called it a solution.
And it still fails: on a slow connection the timeout fires first, the original variant paints, then the experiment applies — flicker plus a blank period.
Why CLS specifically
CLS measures unexpected layout shift. The score for a shift is the impact fraction multiplied by the distance fraction, summed over the worst 5-second session window. Per web.dev, 0.1 or under is good and above 0.25 is poor.
Client-side personalisation is efficient at generating it:
| Change | Typical effect |
|---|---|
| Swap a price string | Small — width change reflows the line |
| Swap headline copy | Medium — line count can change, pushing content |
| Show/hide a banner | Large — everything below moves |
| Replace a hero image | Large — dimension change reflows the fold |
| Reorder sections | Severe |
Because personalisation usually targets the top of the page, the shifted content is the visible content, which is exactly what CLS weights most heavily.
There is a related failure worth naming: flash of original content. The visitor in Germany loads a pricing page, sees $49, and 400ms later it becomes €45. Even if you reserve the space perfectly and CLS is zero, they saw the wrong currency. That is a trust problem, not a metrics problem, and reserving space does not fix it.
The edge is where the decision belongs
The request already passes through a server. Cloudflare Workers, Vercel Edge Middleware, Fastly Compute, Netlify Edge Functions — all of them run code at a PoP near the user, before the response is sent.
That is the correct place to decide what document the user gets. The browser then receives one document, correct on first paint. Nothing to shift, nothing to flash, no anti-flicker snippet.
The request already carries what you need:
| Signal | Where |
|---|---|
| Country, region, city | request.cf.country (Cloudflare), request.geo (Vercel) |
| Currency hint | Derived from country |
| Language preference | Accept-Language header |
| Device class | Sec-CH-UA-Mobile, User-Agent |
| Returning visitor | First-party cookie |
| Experiment bucket | First-party cookie, or deterministic hash |
| Bot vs human | User-Agent, verified via reverse DNS |
None of it requires JavaScript in the browser.
Geo-pricing at the edge
// Cloudflare Worker — HTMLRewriter streams the transform.
// No full-document buffering; time to first byte is barely affected.
const PRICING = {
US: { symbol: '$', amount: '49', code: 'USD' },
GB: { symbol: '£', amount: '39', code: 'GBP' },
DE: { symbol: '€', amount: '45', code: 'EUR' },
IN: { symbol: '₹', amount: '3,499', code: 'INR' },
AU: { symbol: 'A$', amount: '79', code: 'AUD' },
};
const DEFAULT = PRICING.US;
class PriceRewriter {
constructor(price) { this.price = price; }
element(el) {
const field = el.getAttribute('data-price-field');
if (field === 'symbol') el.setInnerContent(this.price.symbol);
if (field === 'amount') el.setInnerContent(this.price.amount);
if (field === 'code') el.setInnerContent(this.price.code);
}
}
export default {
async fetch(request, env, ctx) {
const country = request.cf?.country ?? 'US';
const price = PRICING[country] ?? DEFAULT;
const cacheKey = new Request(
new URL(request.url).toString() + `#price-${country}`,
request
);
const cache = caches.default;
const hit = await cache.match(cacheKey);
if (hit) return hit;
const origin = await fetch(request);
if (!origin.headers.get('content-type')?.includes('text/html')) {
return origin;
}
const response = new HTMLRewriter()
.on('[data-price-field]', new PriceRewriter(price))
.transform(origin);
// Clone before returning; body is a stream and can be consumed once.
const cacheable = new Response(response.body, response);
cacheable.headers.set('Cache-Control', 'public, max-age=300');
cacheable.headers.set('Vary', 'CF-IPCountry');
ctx.waitUntil(cache.put(cacheKey, cacheable.clone()));
return cacheable;
},
};
The origin template:
<p class="price">
<span data-price-field="symbol">$</span><span data-price-field="amount">49</span>
<span class="price__period">/month</span>
<span class="visually-hidden" data-price-field="code">USD</span>
</p>
The default values are real, not placeholders. If the Worker fails, the origin HTML is served unmodified and correct for US visitors — a degraded experience rather than a broken one.
HTMLRewriter is a streaming parser. It transforms tokens as they pass through, so the response starts flowing to the browser before the whole document has been processed — which is what distinguishes edge rewriting from server-side templating that buffers a full render.
We are not going to quote you a TTFB number for this. The overhead depends on your document size, the number of selectors you attach, and what your handlers do. Measure it: record TTFB with the Worker bypassed and with it active, over the same route, and publish the delta you actually see.
A/B testing at the edge
The bucketing must be deterministic — the same visitor must get the same variant on every request, or your results are noise.
// Vercel Edge Middleware
import { next, rewrite } from '@vercel/edge';
export const config = { matcher: ['/pricing', '/'] };
const EXPERIMENT = 'pricing-layout-v3';
const VARIANTS = ['control', 'treatment'];
async function bucket(id, experiment) {
const data = new TextEncoder().encode(`${experiment}:${id}`);
const digest = await crypto.subtle.digest('SHA-256', data);
const n = new DataView(digest).getUint32(0);
return VARIANTS[n % VARIANTS.length];
}
export default async function middleware(request) {
const url = new URL(request.url);
// Never bucket crawlers. Serving variants to Googlebot or GPTBot
// is cloaking risk and pollutes your experiment data.
const ua = request.headers.get('user-agent') ?? '';
if (/bot|crawler|spider|GPTBot|ClaudeBot|PerplexityBot/i.test(ua)) {
return rewrite(new URL('/pricing/control', url));
}
let id = request.cookies.get('vid')?.value;
let isNew = false;
if (!id) { id = crypto.randomUUID(); isNew = true; }
const variant = await bucket(id, EXPERIMENT);
const response = rewrite(new URL(`/pricing/${variant}`, url));
if (isNew) {
response.cookies.set('vid', id, {
httpOnly: true, // not readable by JS — survives ITP's 7-day cap
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365,
path: '/',
});
}
// Expose the variant for analytics without a client-side lookup.
response.headers.set('x-experiment-variant', variant);
return response;
}
Three details that matter:
Crawlers get the control variant, deterministically. Serving different content to a crawler than to users is a cloaking risk, and randomly bucketing crawlers corrupts your data with traffic that never converts.
The cookie is httpOnly. This is the fix for the second defect from the top of the article. Set from the server via Set-Cookie, the assignment is not subject to ITP’s seven-day cap on script-writable cookies, so a Safari visitor returning after three weeks gets the variant they were originally assigned. A client-side framework writing the same cookie via document.cookie loses it after seven days and re-buckets them.
One caveat carried over from the same ITP behaviour: since Safari 16.4, a server-set cookie can still be capped if Safari classifies the setting server as third-party — CNAME cloaking, or an IP range diverging from your main site. Setting the cookie from your primary origin, as edge middleware does, avoids this. Setting it from a vendor subdomain may not.
Bucketing is a hash, not a random number. Math.random() at request time gives a different variant on every page load.
Caching without a cardinality explosion
The objection to edge personalisation is that it defeats caching. Handled correctly, it does not.
Cache key on the decision, not on the input. Do not vary on country — vary on pricing region. Forty countries mapping to five regions is five cache entries, not forty.
const REGION = {
US:'NA', CA:'NA',
GB:'UK',
DE:'EU', FR:'EU', ES:'EU', IT:'EU', NL:'EU', // ...
AU:'APAC', NZ:'APAC',
};
const region = REGION[country] ?? 'NA';
const cacheKey = `${url.pathname}#${region}`;
Count your dimensions before you add one. Entries multiply: 5 regions × 2 variants × 2 device classes = 20 copies of every page. Each dimension you add multiplies again. Be deliberate.
Some things do not belong at the edge. A logged-in user’s name, cart contents, account state — that is per-user data, uncacheable by definition. Leave it client-side, but reserve its space in the layout so it cannot shift:
.account-name {
min-inline-size: 8ch; /* space reserved before the value arrives */
min-block-size: 1lh;
}
The rule: edge-render what is shared by a segment, client-render what is unique to a person, and reserve space for anything client-rendered.
What this fixes
| Client-side | Edge | |
|---|---|---|
| Layout shift from personalisation | Yes | None |
| Flash of original content | Yes | None |
| Anti-flicker snippet needed | Yes | No |
| LCP blocked by experiment script | Yes | No |
| JS shipped for personalisation | Vendor snippet + framework | 0 KB |
| Correct for non-JS crawlers | No | Yes |
| Works with ad-blockers | Often not | Yes |
The crawler row deserves attention given how many teams now care about AI citation. Client-side personalisation is invisible to every non-Google crawler — they see the unmodified origin HTML. Edge rewriting produces a real document that any crawler reads correctly, which is one more reason the decision belongs upstream of the browser.
Measuring whether it worked
Lab tools will not show you this. Lighthouse runs on a clean profile with no cookies, from a single location, and personalisation is invisible to it by construction. You need field data.
Isolate CLS attribution in the field. The LayoutShift entry exposes the nodes that moved, which turns a score into a diagnosis:
import { onCLS } from 'web-vitals/attribution';
onCLS(({ value, attribution }) => {
navigator.sendBeacon('/metrics', JSON.stringify({
metric: 'CLS',
value,
// The specific element responsible for the largest shift
element: attribution.largestShiftTarget,
time: attribution.largestShiftTime,
url: location.pathname,
}));
}, { reportAllChanges: false });
Ship that before the migration and after. If largestShiftTarget was div.pricing-card beforehand and is absent afterwards, you have a direct causal answer rather than a correlation with a total score.
Watch the right pair of metrics. Removing an anti-flicker snippet moves two things at once:
| Metric | Expected direction |
|---|---|
| CLS | Down — the personalisation shift is gone |
| LCP | Down — no longer blocked behind opacity: 0 |
| INP | Down slightly — less main-thread JS competing |
| TTFB | Up slightly — edge compute adds a few ms |
TTFB rising while LCP falls is the correct outcome, not a regression. The edge is doing a small amount of work to avoid a large amount of browser work. If TTFB rises substantially rather than marginally, you are probably buffering the response rather than streaming it — check that you are using HTMLRewriter or an equivalent streaming transform, and not await response.text() followed by a string replace.
Verify the variant split is actually even. Deterministic hashing is only as good as the input distribution. Log the bucket assignment server-side and check the ratio after a day of real traffic:
response.headers.set('Server-Timing', `variant;desc="${variant}"`);
Server-Timing surfaces in the browser’s Network panel and in RUM tooling, which makes the assignment debuggable without adding a client-side lookup.
Frequently asked questions
Does edge personalisation hurt Time to First Byte? Marginally, when done with a streaming rewriter that transforms tokens as they pass rather than buffering the document. The size of the overhead depends on your document and your handlers, so measure it on your own route rather than trusting a quoted figure. Buffering the full response before transforming does hurt TTFB measurably — avoid that pattern.
Is serving different HTML by location cloaking? No, provided you serve crawlers the same content you would serve a user from that crawler’s location, and you are not detecting the crawler in order to show it something different. Geo-variation and A/B testing are both explicitly acceptable. Bucketing crawlers into random variants is the practice to avoid.
How do I stop my cache hit rate collapsing? Key the cache on the decision rather than the raw signal — pricing region, not country. Count your dimensions before adding one, since they multiply.
Can I keep using my existing A/B testing tool? Most now offer a server-side or edge SDK. The client-side snippet with the anti-flicker style block is the pattern to retire; the vendor’s own edge integration is usually a supported migration path.
What about logged-in personalisation?
Per-user data is uncacheable and belongs client-side. Reserve its layout space with min-inline-size and min-block-size so the eventual render cannot shift anything.
Does this work on static hosting? It requires an edge runtime. Cloudflare Workers, Vercel Edge Middleware, Netlify Edge Functions, and Fastly Compute all layer over static origins, so you keep static hosting and add a transform in front.
Why httpOnly on the experiment cookie?
Safari’s ITP caps script-written cookies at seven days. A server-set httpOnly cookie is not subject to that cap, so visitors keep their variant assignment. Client-set cookies silently re-bucket returning Safari users after a week.
Sources
- Cloudflare
HTMLRewriterdocumentation - Vercel Edge Middleware documentation
- web.dev: Cumulative Layout Shift — definition and thresholds
- WebKit: Full Third-Party Cookie Blocking and More — the seven-day cap on script-written cookies
Claims last verified 25 July 2026. Code samples written against current platform APIs. See our editorial standards.