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.

Edge personalisation pipeline Steps from a request through variant key derivation, cache lookup and streaming HTML rewriting to the personalised response. Edge personalisation pipeline Request cookies, geo, headers Derive key currency + arm Cache lookup shared variant Rewrite stream greeting, banner Response TTFB ~80ms

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, or set-cookie on 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.

How to handle each personalisation dimension Common personalisation dimensions by cardinality and the recommended way to serve each from a cached page. How to handle each personalisation dimension Dimension Cardinality Approach Currency / country tens cache key at the edge Experiment arm 2-3 cache key at the edge First-name greeting per user rewrite in stream from cookie or token Cart count per user fragment or client fetch Recommendations per user, slow client fetch after paint

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

javascript
// 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

javascript
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.

TTFB for a logged-in visitor before and after edge personalisation Two timelines comparing time to first byte for a logged-in visitor when the page is rendered at the origin and when it is served from cache and rewritten at the edge. TTFB for a logged-in visitor before and after edge personalisation Origin render edge origin render Edge rewrite edge 0ms 100ms 200ms 300ms 400ms 500ms 600ms 700ms 800ms 200ms

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.