How to Personalize Cached Pages at the Edge
This guide puts Edge Compute & Dynamic Caching into practice within Advanced Caching Strategies & CDN Architecture. The scenario: a page is identical for everyone except for a few details — a price shown in the visitor's currency, a "Free delivery to Germany" banner, a greeting with the user's first name, the experiment arm of a headline test. Rendering all of that at the origin forces the page out of the cache. Varying the cache on cookies fragments it into millions of entries that never warm.
Edge personalisation splits the work. The cache stores one shared document (or a handful of variants keyed by low-cardinality values), and an edge function rewrites the small personal parts as the document streams through. Because the rewriting happens on a cached response, close to the user, the origin is not involved and TTFB stays near cache-hit speed — typically well under 200ms — for every visitor.
Rapid Diagnosis
- Find what personalises the page today. Search templates for reads of cookies, geo headers, user objects or experiment assignments during render.
- Count distinct values per dimension. Currency (10), country (60), experiment arm (2–3), user (millions). Low-cardinality dimensions can be cache keys; user-level ones cannot.
- Check current cache behaviour. HTML responses with
Cache-Control: private,Vary: Cookie, orset-cookieon every response are not being cached. - Measure TTFB split by personalisation. Compare anonymous, logged-in and experiment-exposed traffic in RUM.
Root Cause Analysis
1. Personalisation during origin render. Any per-request value used while rendering makes the whole document uncacheable, even if it affects ten bytes.
2. Varying on high-cardinality inputs. Vary: Cookie or keying on the full cookie header produces a separate cache entry per visitor.
3. Set-Cookie on cacheable responses. Many CDNs refuse to cache responses that set cookies, so a session-refresh cookie on every page disables caching silently.
4. Client-side rewriting as a workaround. Swapping content after load with JavaScript causes flicker and layout shift, and can produce a later LCP if the swapped element is large.
Step-by-Step Resolution
1. Render the shared document with neutral placeholders
Change templates so the origin renders personal regions as stable placeholders with fixed dimensions: <span data-slot="greeting">Welcome</span>, <span data-slot="cart-count">0</span>. The origin response no longer depends on who asked, so it can be cached publicly.
2. Derive a variant key at the edge
// Cloudflare Worker: currency and experiment arm as the only variant dimensions.
function variantKey(request) {
const country = request.cf?.country ?? 'US';
const currency = { DE: 'EUR', FR: 'EUR', GB: 'GBP', US: 'USD' }[country] ?? 'USD';
const arm = /exp_hero=(a|b)/.exec(request.headers.get('cookie') ?? '')?.[1] ?? 'a';
return `${currency}:${arm}`;
}
// trade-off: every value added to the key multiplies cache entries per URL.
// Keep the key to dimensions that change the first paint; adjust the rest
// client-side or in the stream.
Expected outcome: a handful of cache entries per URL instead of one per visitor.
3. Rewrite personal slots in the stream
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const cacheKey = new Request(`${url.origin}${url.pathname}?__v=${variantKey(request)}`);
let response = await caches.default.match(cacheKey);
if (!response) {
response = await fetch(request, { headers: { 'x-variant': variantKey(request) } });
ctx.waitUntil(caches.default.put(cacheKey, response.clone()));
}
const name = decodeURIComponent(/first_name=([^;]+)/.exec(request.headers.get('cookie') ?? '')?.[1] ?? '');
return new HTMLRewriter()
.on('[data-slot="greeting"]', { element(el) { if (name) el.setInnerContent(`Welcome back, ${name}`); } })
.transform(response);
},
};
// trade-off: setInnerContent escapes text by default, which is correct for
// names. Never insert raw HTML from cookies — that is an injection vector.
Expected outcome: a cached response, personalised in transit, with TTFB close to a plain cache hit.
4. Strip cookies from cacheable responses
Make sure the origin does not send Set-Cookie on cacheable HTML. Refresh session cookies on API calls or a dedicated endpoint instead.
Verification
Request the page as several test users and from several countries (via a VPN or the platform's preview tools) and confirm each response is correctly personalised and that cache status headers show hits after the first request per variant. Run a cross-user isolation test. In RUM, TTFB p75 for logged-in and experiment traffic should converge on anonymous TTFB.
Worked Example: Currency and Greeting on a Fashion Store
A fashion retailer rendered prices in the visitor's currency and a "Hi, Sam" greeting at the origin, so no product page was ever cached. They changed templates to render prices for a currency passed in a request header and the greeting as a placeholder, keyed the edge cache on currency only (seven values), and filled the greeting from a first_name cookie with HTMLRewriter. The HTML hit ratio for product pages rose from 0% to 91%, TTFB p75 fell from 690ms to 90ms, and LCP p75 improved by about 550ms. Because the greeting placeholder had the same dimensions as the greeting, there was no layout shift.
Common Mistakes
- Keying on the raw cookie header. It contains analytics IDs that differ per visitor; derive a clean key instead.
- Personalising the LCP element client-side. Swapping a hero image after load creates a later LCP; personalise it at the edge or not at all.
- Inserting unescaped user data. Cookie values are attacker-controlled; always escape.
- Forgetting to vary purges. When a product changes, purge every variant — tag them all with the product's tag.
Edge Cases
Bots and crawlers. Search engines should see the default variant; ensure the variant key falls back to a sensible default without cookies or geo data.
Users switching currency. A currency switcher must set a cookie the edge reads; avoid query-string currencies that fragment the cache further.
Streaming and late slots. If a personal slot appears late in a large document, the rewriter still streams earlier chunks immediately; TTFB is unaffected.
Platform differences. Fastly (VCL or Compute), Akamai and Vercel offer equivalent mechanisms with different APIs; the pattern — small key, cached document, streaming rewrite — is the same.
FAQ
Is it safe to read a first name from a cookie?
It is safe if you escape it and treat it as display-only. Do not derive authorisation or anything sensitive from client-readable cookies; for that, use server-validated tokens on API calls.
Does HTML rewriting add noticeable latency?
Streaming rewriters operate on chunks as they pass and add a few milliseconds at most. Buffering the entire response to run string replacements is what adds latency — avoid it.
What if personalisation needs data from a database?
Then it belongs in a fragment fetched in parallel (or on the client), not in the shell. Edge functions should not block the cached shell on slow, distant data stores.
How does this interact with Vary headers?
You stop using Vary for personalisation: the edge computes the key explicitly. Remove Vary: Cookie from origin responses, or the CDN may still fragment the cache.
Can I personalise above-the-fold images this way?
Yes, by rewriting the src/srcset attributes of the hero in the stream based on a variant key. Because it happens before the HTML reaches the browser, the preload scanner sees the correct image and LCP is unaffected.
How many variants per URL is too many?
As a rule of thumb, keep it under a few dozen. Each variant must be requested at least once per location before it is cached, and rarely requested variants expire before they are reused. If your key multiplies several dimensions, check per-variant hit ratios; variants with low hit ratios should be collapsed or handled client-side.
Related
- Caching HTML for logged-in users safely — the correctness rules behind this pattern.
- Normalizing cache keys to raise hit rate — designing the variant key.
- Using the Cache API in Cloudflare Workers — the cache calls used above.