MODRACXKENNETH D'SILVA

← Archive & Insights

Leveraging Edge Computing for Real-Time Personalization

They shipped edge personalisation on a Tuesday. By Thursday their HTML cache hit ratio had fallen from 91% to 12% and TTFB was up thirteenfold, all from one honest, standards-compliant Vary header.

By Kenneth D'SilvaReading Time: 26 min readCategory: Architecture & Cloud

1. The Day Our Cache Hit Ratio Fell From 91% to 12%

A garden machinery retailer launched edge personalisation on a Tuesday. The feature was modest: show a returning customer their nearest store's stock, and swap the hero banner for the category they had browsed most in the last thirty days. Two small changes on the homepage and the category pages.

By Thursday their CDN hit ratio on HTML had gone from 91% to 12%. Time to first byte at the 75th percentile went from 62ms to 840ms. Origin request volume increased by roughly a factor of eight and their commerce API started shedding requests during the evening peak. Conversion for the week was down 4%, which is the number that got me a phone call.

The cause was one line. To make the personalisation work, someone had added Vary: Cookie to the HTML response. That is the textbook-correct HTTP answer — the response does vary by cookie, and telling caches so is honest. It is also catastrophic, because their cookie header contained a session id, a consent string, three analytics identifiers and a cart token. Every visitor had a unique cookie header, so every visitor had a unique cache entry, so the cache stored one copy per person and served it to nobody twice.

They had not built a personalisation system. They had built an extremely expensive way to disable caching.

This is the central difficulty of personalisation at the edge, and it is not a difficulty about edge runtimes or isolates or latency — I have written about those in the edge computing fundamentals piece, and about the platform mechanics in the Vercel and Next.js article. The difficulty is that caching and personalisation are opposites. A cache is valuable in proportion to how many people share a response. Personalisation is valuable in proportion to how much responses differ per person. Every design decision in this space is a negotiation between those two, and the entire craft is in keeping the number of distinct responses small while making each visitor feel the page was assembled for them.

2. Cardinality Is the Only Number That Matters

Before any implementation question, work out how many distinct versions of the page you are about to create. Multiply out every dimension you vary on.

Four countries times three currencies times two device classes times a logged-in flag times four browse-affinity segments times two A/B variants is 384 versions of every page. If your product catalogue is 40,000 SKUs, that is 15.36 million cache entries. Your CDN will happily store some of them, evict most, and serve almost every visitor a miss. The 91-to-12 collapse above was the same arithmetic with an effectively infinite denominator.

I now write this multiplication down on the first call, before anyone has drawn an architecture. It settles arguments quickly, because a marketing team asking for eight segments will usually take four when they see the number that eight produces.

Variants per pageEffective hit ratio (typical)What it feels like
190–97%Fast everywhere, nothing personalised
2–485–93%Barely distinguishable from no variants
8–1660–80%Noticeably slower long tail, higher origin load
32–6425–50%Cache is doing little; origin is the bottleneck
Unbounded (per-user)<15%You have disabled your CDN and are paying for it

Those percentages are approximate and depend heavily on your traffic distribution — a site where 80% of traffic hits 200 URLs tolerates more variants than one with a flat long tail. But the shape holds. My working rule is that a page should have at most eight variants, and I have never regretted arguing for four.

There is a second cost that is easy to miss: each variant regenerates independently. If your revalidation window is five minutes and you have sixteen variants, the page is regenerated sixteen times per window rather than once. On a headless build that is sixteen times the API calls, and rate limits on commerce APIs are the thing that actually breaks first.

3. Why Vary Is the Wrong Tool

Vary is the standards-compliant mechanism and it is almost never what you want on HTML at a CDN. Three reasons.

It operates on whole headers. Vary: Cookie keys on the entire cookie header, not on the one cookie you care about. There is no standard way to say "vary on the value of the seg cookie". Since a real storefront sets a dozen cookies with high-entropy values, varying on cookie is varying on visitor identity.

Browsers and CDNs disagree about it. Shared caches you do not control — corporate proxies, ISP caches, some mobile carriers — implement Vary with varying enthusiasm. A cache that ignores Vary will serve one visitor's personalised page to another, which on a page containing a name or an address is a data incident rather than a bug.

It is invisible in your own metrics. Your CDN reports a hit ratio, and a Vary-fragmented cache reports a lot of misses without telling you why. The retailer above spent two days looking at origin performance before anyone looked at the response headers.

Vary: Accept-Encoding is fine and necessary. Vary: Accept-Language is defensible for a small language set, though I would rather encode language in the URL. Vary: Cookie on an HTML document is, in my experience, always a mistake.

Some CDNs have proprietary alternatives that solve the granularity problem properly. Netlify's Netlify-Vary can key on a named cookie, a named query parameter, a language, or a country. Cloudflare Enterprise offers custom cache keys that can include a specific cookie. These are genuinely better than Vary and they are also vendor lock-in, so know what you are choosing.

Netlify-Vary: cookie=seg,country=GB|DE|NL|IE,header=x-experiment

4. The Cache Key Is the Real Control Surface

The portable technique, and the one I reach for first, is to normalise the visitor into a short segment token at the edge and put that token into the cache key rather than into a Vary header. There are two ways to do it and both are fine.

Rewrite the path. The segment becomes part of the URL the cache sees, while the customer's address bar is unchanged. This works on every platform, needs no proprietary features, and has the enormous debugging advantage that you can request a variant directly with curl.

// Vercel middleware: derive a small segment token, rewrite to a variant path.
import { NextResponse } from 'next/server';

const COUNTRIES = new Set(['GB', 'IE', 'DE', 'NL']);

export function middleware(request) {
  const url = request.nextUrl;

  // Country from the edge, clamped to markets we actually serve.
  const raw = request.headers.get('x-vercel-ip-country') ?? 'GB';
  const country = COUNTRIES.has(raw) ? raw : 'GB';

  // Affinity segment, already bucketed to one of four values by the job
  // that writes the cookie. Never trust it — clamp on read.
  const seg = ['new', 'browse', 'lapsed', 'loyal']
    .includes(request.cookies.get('seg')?.value)
      ? request.cookies.get('seg').value : 'new';

  // Two dimensions, four values each: sixteen variants, not sixteen million.
  url.pathname = `/_v/${country}/${seg}${url.pathname}`;
  return NextResponse.rewrite(url);
}

export const config = {
  matcher: ['/', '/category/:path*', '/product/:path*']
};

Modify the cache key directly. On Cloudflare Enterprise, Fastly, or Akamai you can compute a key that includes a normalised token without changing the path. Cleaner, and unavailable on lower tiers.

// Cloudflare Worker: explicit cache key, not the request URL.
export default {
  async fetch(request, env, ctx) {
    const country = request.headers.get('cf-ipcountry') ?? 'GB';
    const seg = normaliseSegment(request);

    // The key is a synthetic URL. Nothing else about the request
    // participates, so tracking parameters cannot fragment the cache.
    const url = new URL(request.url);
    url.search = '';                        // drop utm_*, gclid, fbclid, all of it
    const key = new Request(
      `${url.origin}${url.pathname}?__v=${country}.${seg}`,
      { method: 'GET', headers: { accept: request.headers.get('accept') ?? '' } }
    );

    const cache = caches.default;
    let res = await cache.match(key);
    if (res) return res;

    res = await fetch(request);
    res = new Response(res.body, res);
    res.headers.set('cache-control', 'public, max-age=300, stale-while-revalidate=86400');
    ctx.waitUntil(cache.put(key, res.clone()));
    return res;
  }
};

Note the url.search = '' line. Query-string fragmentation is a bigger cache killer than personalisation on most storefronts. A campaign that appends five tracking parameters in varying orders produces a distinct cache entry per permutation, and the pages are byte-identical. Stripping and sorting query parameters before the key is computed routinely doubles hit rate on its own, before you personalise anything.

5. Segments, Not Individuals

The design move that makes all of this tractable is to stop thinking about the visitor and start thinking about the bucket. The edge should never know who someone is. It should know which of a handful of buckets they fall into.

The bucket assignment happens somewhere with more time and more data — a nightly job over your order history, a CDP, or a simple rule in your commerce backend — and the result is written into a cookie as a single short value. The edge reads that value, validates it against a hard-coded allowlist, and uses it. That is the entire contract.

// The whole cookie. One character of segment, one of experiment variant,
// and a version so we can invalidate everyone's assignment on demand.
// Set with SameSite=Lax; not HttpOnly if the client needs to read it.
// Deliberately no user id: the edge has no business knowing one.
document.cookie = 'p=v2.loyal.b; Path=/; Max-Age=2592000; SameSite=Lax; Secure';

Two properties of this that matter more than they look.

It is validated on read. An attacker or a curious customer editing the cookie to p=v2.'; DROP should hit an allowlist check and fall back to the default bucket. Anything derived from a client-controlled value and used to build a cache key is an opportunity for cache poisoning, and a cache-poisoned page is served to every subsequent visitor. This is the highest-severity failure mode in this whole area and it gets almost no attention.

const SEGMENTS = ['new', 'browse', 'lapsed', 'loyal'];
const VARIANTS = ['a', 'b'];

function parseCookie(value) {
  const [version, seg, variant] = (value ?? '').split('.');
  return {
    seg:     SEGMENTS.includes(seg) ? seg : 'new',
    variant: VARIANTS.includes(variant) ? variant : 'a',
    stale:   version !== 'v2'          // reassign on the next response
  };
}

And it is versioned. When the segmentation model changes, bumping the version prefix means every cookie is treated as stale and reassigned, without needing to expire anything. It also gives you a clean way to purge: a new version prefix means new cache keys, so the old variants age out naturally rather than needing a mass invalidation.

One more discipline: keep the number of segments in the cache key smaller than the number of segments in your marketing model. Not every distinction the business cares about needs to change the cached document. A segment can drive a client-side module without ever touching the cache key, and most of them should.

6. Three Shapes of Personalisation, and How to Choose

Every implementation is one of three patterns or a mixture. Choosing deliberately is most of the work.

Variant documents. N complete cached copies of the page, one per segment. Fast for everyone, indexable, no client-side flash. Costs you cache cardinality and regeneration multiples. Right when the differences are structural — a different currency, a different language, a genuinely different layout for a market.

Hole punching. One cached document with placeholders that are filled at the edge or on the client. Cardinality of one. The classic implementation is Edge Side Includes on Fastly or Akamai, where the edge assembles fragments with different TTLs; the modern equivalent is a streamed placeholder that a small client fetch resolves. Right when the personalised part is a small, non-critical region of the page.

Client-side hydration. The page is fully static and JavaScript rewrites the personalised bits after load. Cardinality of one, zero edge complexity, and a visible flash of the default content if you are not careful. Right for recommendations, recently viewed, and anything below the fold.

PatternCache cardinalityIndexed?Flash riskBest for
Variant documentsN per pageYes, all variantsNoneCurrency, language, market
Edge hole punching (ESI)1 shell + N fragmentsShell onlyNoneStore stock, greeting, basket count
Streamed placeholder1Shell onlyLowRecommendations, reviews
Client-side hydration1NoHigh if above foldRecently viewed, below-fold modules

My default allocation on a storefront: currency and language as variant documents because they must be correct in the HTML; basket count, greeting and store stock as hole-punched fragments; everything the recommendation engine produces as client-side hydration, because it is below the fold and nobody has ever left a site because a "you might also like" strip arrived 300ms late.

The flash question deserves its own note, because it is where the client-side option goes wrong. Rendering a default price and then replacing it 400ms later is not a cosmetic problem on a furniture site — customers read it as a price change and complain. Reserve the space, render a skeleton rather than a wrong value, and if the value is one a customer would notice changing, it belongs in the document rather than in a client fetch.

7. What the Edge Actually Knows About Location

Geo headers are the most commonly used personalisation input and the most commonly over-trusted. Every major platform gives you country, most give region and city, some give timezone, latitude and longitude.

// Cloudflare
const geo = {
  country: request.headers.get('cf-ipcountry'),
  city:    request.cf?.city,
  region:  request.cf?.region,
  tz:      request.cf?.timezone
};

// Vercel
const geo2 = {
  country: request.headers.get('x-vercel-ip-country'),
  region:  request.headers.get('x-vercel-ip-country-region'),
  city:    decodeURIComponent(request.headers.get('x-vercel-ip-city') ?? '')
};

Country is reliable enough to act on — typically 97–99% accurate. City is not. IP geolocation puts a meaningful fraction of UK mobile traffic in whichever city hosts the carrier's gateway, which is why a customer in Newcastle gets told their nearest store is in Leeds. VPN use, corporate networks and IPv6 allocations all degrade it further.

The rules I apply:

Use country for currency, tax display, shipping messaging and legal requirements. These are country-level concerns anyway and the accuracy is adequate.

Never use city for anything the customer would notice being wrong. Nearest-store logic should be a prompt, not a decision: "Showing stock for Leeds — change store" with the change control visible and one click away.

Never redirect on geography alone. This is the mistake I see most and it damages more than user experience. Googlebot crawls predominantly from US addresses, so a hard geo-redirect sends the crawler to a US store view and your UK category pages quietly leave the index. Redirect only when there is no explicit locale in the path and no locale cookie, and exclude known crawler user agents entirely. Offer a banner rather than a bounce.

const CRAWLER = /googlebot|bingbot|applebot|duckduckbot|yandex|baiduspider|petalbot/i;

function shouldRedirect(request, pathname) {
  if (CRAWLER.test(request.headers.get('user-agent') ?? '')) return false;
  if (/^\/(gb|ie|de|nl)\//.test(pathname)) return false;   // explicit choice in URL
  if (request.cookies.has('locale')) return false;          // remembered choice
  return true;
}

Let the customer override, and remember it forever. An explicit choice beats an inference every time, and the cookie that records it should outlive the session.

8. Currency and Price: The Case That Is Genuinely Hard

Price display is where personalisation stops being a nice-to-have and becomes a correctness problem, and it is the one I get asked about most.

The temptation is to render prices client-side from a rate table, keeping cardinality at one. Three things go wrong. The flash, discussed above. The structured data, which either shows the base currency (inconsistent with the visible price) or has to be rewritten by JavaScript (unreliable for indexing). And VAT-inclusive versus VAT-exclusive display, which is a legal requirement that differs by market and cannot be a client-side approximation.

So currency is a variant document. It is one of the few dimensions I am happy to spend cache cardinality on, because getting it wrong is a compliance issue rather than a conversion issue.

// Currency belongs in the HTML, in the structured data, and in the cache key.
const schema = {
  '@context': 'https://schema.org',
  '@type': 'Product',
  name: product.name,
  offers: {
    '@type': 'Offer',
    // Must match the price the visitor sees on the page. Google flags
    // mismatches between markup and visible price as a structured data error.
    price: variantPrice.amount,
    priceCurrency: variantPrice.currency,
    priceValidUntil: product.priceValidUntil,
    availability: product.inStock
      ? 'https://schema.org/InStock'
      : 'https://schema.org/OutOfStock'
  }
};

Keep the number of currencies in the cache key to the ones you actually transact in. I have seen a site key on eleven currencies because the rate table had eleven rows, while 96% of orders were in three. Three variants plus a client-side approximate conversion for everyone else, with a clear "you will be charged in GBP" note, is the right trade.

The related trap is B2B contract pricing. If different customers see different prices for the same SKU, the page is not cacheable at all for logged-in visitors, and no amount of segmentation fixes that — contract pricing is per-account by definition. The answer is to cache the anonymous page, serve it to everyone, and fetch contract prices client-side after authentication. Anonymous traffic is the traffic that gets indexed and the traffic that is most latency-sensitive, and it is where the cache value is.

9. A/B Assignment at the Edge

Experiment assignment is a natural fit for the edge: it needs no origin data, it must happen before the page renders to avoid a flash, and it must be sticky.

The mechanism is a hash. Take a stable visitor identifier, concatenate an experiment-specific salt, hash it, take the result modulo 100, and compare against the allocation. Same input, same bucket, forever, with no storage and no coordination between PoPs.

// FNV-1a: fast, synchronous, no crypto needed. This is bucketing,
// not security, so a non-cryptographic hash is the correct choice.
function hash(str) {
  let h = 0x811c9dc5;
  for (let i = 0; i < str.length; i++) {
    h ^= str.charCodeAt(i);
    h = Math.imul(h, 0x01000193) >>> 0;
  }
  return h;
}

function assign(visitorId, experiment) {
  // The salt is per-experiment. Without it, a visitor lands in the same
  // relative position in every test and your experiments correlate.
  const bucket = hash(`${visitorId}:${experiment.salt}`) % 100;
  let cursor = 0;
  for (const arm of experiment.arms) {
    cursor += arm.weight;
    if (bucket < cursor) return arm.id;
  }
  return experiment.arms[0].id;
}

The per-experiment salt is the detail people skip and it invalidates results when they do. Without it, the visitor who landed in the top decile of one hash lands in the top decile of every hash, so the population in variant B of test one is the same population as variant B of test two. Your tests are no longer independent and you will not notice until two experiments disagree with each other in a way that makes no sense.

The visitor identifier should be a first-party cookie you set yourself. Do not hash the IP address — it is unstable for mobile users, shared behind corporate NAT, and in several jurisdictions it is personal data, so hashing it for behavioural bucketing is a consent question you did not mean to open.

Then the important part: the assignment has to reach the cache key, or every visitor gets whichever variant happened to be cached first.

export function middleware(request) {
  let vid = request.cookies.get('vid')?.value;
  const isNew = !vid;
  if (isNew) vid = crypto.randomUUID();

  const arm = assign(vid, EXPERIMENTS.pdpLayout);

  const url = request.nextUrl;
  url.pathname = `/_ab/${arm}${url.pathname}`;      // variant in the cache key
  const res = NextResponse.rewrite(url);

  if (isNew) {
    res.cookies.set('vid', vid, {
      path: '/', maxAge: 31_536_000, sameSite: 'lax', secure: true
    });
  }
  // Exposure logging, off the critical path. Never await this.
  res.headers.set('x-ab', `${EXPERIMENTS.pdpLayout.id}:${arm}`);
  return res;
}

Some further points that separate an experiment that produces a usable result from one that does not.

Log exposure, not assignment. Record that a visitor saw a variant, from the client, not that the edge computed one. Assignment happens on prefetches, bot requests, and pages that never render. Analysing on assignment inflates your denominator with traffic that never saw anything.

Check for sample ratio mismatch before reading the result. A 50/50 split that arrives at 52/48 with a large sample is not noise, it is a bug — usually a cache serving one variant to visitors assigned the other, or a bot filter that correlates with the variant. If the ratio is off, the result is void regardless of how good it looks.

Exclude crawlers from experiments entirely. Serve the control arm to any known crawler. It keeps the index stable and keeps bot traffic out of your numbers.

Have a kill switch that is a config read, not a deploy. An experiment that tanks conversion needs to be off in seconds. A KV or Edge Config value checked at the top of the handler does that; a redeploy does not.

10. Personalisation and the Index

Personalising cached pages has search consequences that are easy to trip over and easy to avoid once you know them.

Crawlers see one variant, and it should be the default one. Whatever your edge logic does, a request identifying itself as Googlebot should get the plain, canonical version: control arm, base market, no segment. Not because personalisation is forbidden, but because a stable index is worth more than a personalised crawl.

The line on cloaking is about intent and materiality. Serving a German visitor German content is normal and expected. Serving a crawler substantively different content from what a user in identical circumstances would get is the problem. Vary on explicit signals — a path prefix, a stored preference — and you stay comfortably clear of it.

Market variants need real URLs and hreflang. If the UK and German versions of a page are genuinely different content, they should be /gb/ and /de/ with reciprocal hreflang annotations and self-referencing canonicals, not one URL that changes by IP. The internal variant paths from a middleware rewrite are a different thing entirely — they are invisible to the customer and must never be linked, must never appear in a sitemap, and should carry noindex if there is any chance of them being requested directly.

// Internal variant namespace: unreachable by design, and belt-and-braces.
export function middleware(request) {
  // A direct request to the internal path is either a bug or a probe.
  if (request.nextUrl.pathname.startsWith('/_v/')) {
    return new NextResponse('Not Found', {
      status: 404,
      headers: { 'x-robots-tag': 'noindex, nofollow' }
    });
  }
  return route(request);
}

Edge-injected content is in the HTML and therefore indexable. This is the flip side of the advantage. A personalised greeting rendered at the edge can be indexed; a client-side one generally is not. If you are injecting anything visitor-specific into the document, make sure the crawler path does not.

11. Consent Is a Constraint, Not a Footnote

Under GDPR and the ePrivacy directive, storing or reading information on a user's device for personalisation requires consent, with a narrow exemption for what is strictly necessary to deliver a service the user requested. A cookie that records the market a visitor selected is comfortably necessary. A cookie that records their inferred browse affinity for merchandising is not.

What this means practically for an edge design:

Behaviour-derived segments must be gated on consent. If the visitor has not consented, the segment cookie should not be written and the edge should serve the default variant. Build this in from the start; retrofitting it means auditing every place a cookie is set.

Geo from IP is a grey area worth being careful about. Reading the IP to determine country for currency and shipping is generally defensible as necessary. Storing a geo-derived profile is not.

Design so that no consent means fewer variants, not a broken page. The non-consenting visitor gets the default cached page, which is the fast one. That is a pleasant property: your privacy-respecting path is also your performance path.

function segmentFor(request) {
  // Consent Mode / TCF string, however you store it. No consent, no segment.
  const consent = request.cookies.get('consent')?.value ?? '';
  if (!consent.includes('personalisation')) return 'default';
  return parseCookie(request.cookies.get('p')?.value).seg;
}

I have had this conversation go badly exactly once, on a project where the segmentation had shipped before anyone consulted the client's DPO, and unwinding it took longer than building it had. Have the conversation in week one; it constrains the design, and a constraint you know about is cheap.

12. When the Cookie Is Not Enough: Looking Segments Up at the Edge

Everything above assumes the segment arrives in a cookie the visitor already has. Sometimes it does not. A customer logs in on their phone having been segmented on desktop; a CDP updates someone's tier overnight and the cookie is a month old; a first-time visitor arrives from an email campaign carrying a hashed identifier in the URL. In those cases the edge has an identity and needs to turn it into a segment, which means a lookup.

This is where personalisation projects quietly acquire the latency they were supposed to avoid. A lookup against a store in a single region costs you a round trip from the PoP, and I went through the arithmetic of why that usually loses in the fundamentals article. The constraint here is specific: you are doing this lookup on the request path of a page that was meant to be a cache hit.

Three rules I hold to.

Only look up when you have to, and write the result to a cookie immediately. The lookup should happen once per visitor per month, not once per request. If you find yourself doing it on every page view, the cookie write is broken.

Use an eventually-consistent edge store, and accept the staleness. Cloudflare KV or Vercel Edge Config replicate globally and read locally. A segment that is a minute out of date is fine — segments describe months of behaviour. Do not reach for a strongly-consistent store here; you do not need the consistency and you will pay for it in distance.

Budget the lookup and fail open. Give it a hard timeout, and if it does not answer, serve the default variant. A personalisation lookup must never be able to make the page slow, let alone break it.

async function resolveSegment(request, env) {
  const cookie = request.cookies.get('p')?.value;
  if (cookie) return parseCookie(cookie).seg;      // the 99% path: no I/O at all

  const uid = request.nextUrl.searchParams.get('cid');
  if (!uid) return 'new';

  // 60ms budget. Past that, the default variant is better than a slow page.
  const timeout = new Promise(r => setTimeout(() => r(null), 60));
  const lookup = env.SEGMENTS.get(`u:${uid}`, { cacheTtl: 300 })
    .catch(() => null);

  const found = await Promise.race([lookup, timeout]);
  return SEGMENTS.includes(found) ? found : 'new';
}

One thing I got wrong on an early build: I put the lookup before the cache check, so even returning visitors with a valid cookie paid for a KV read because the code was written top to bottom rather than by cost. Order your handler so the cheapest path exits first. It reads worse and runs better.

13. Doing This on Magento and Shopify

Most storefronts I work on are not a clean Next.js build, and the platform underneath changes what is available.

Shopify. You do not control the CDN or the cache, and Liquid rendering happens on Shopify's infrastructure. Markets handles currency and country properly and you should use it rather than reinventing it at the edge. What is left for an edge layer in front is limited — mostly redirects and bot control — because Shopify is already doing the caching and doing it well. Where the edge becomes relevant is a headless Hydrogen or Next.js storefront, at which point everything in this article applies to your layer and Shopify becomes the data source whose rate limits you must respect. Segment-driven variants multiply your Storefront API calls by the variant count during regeneration, and that is the constraint that bites first.

Magento 2. Full page cache with hole punching is already the native model — private content blocks are punched out of the cached page and filled by a customer-data endpoint on the client. That is precisely the pattern this article recommends, arrived at years earlier for the same reasons. The mistake I see is teams adding edge personalisation on top without noticing that the private-content mechanism exists, ending up with two systems punching holes in the same page and a customer-data call that fires on every navigation. Before you put anything at the edge on Magento, look at what sections.xml is already invalidating; the answer is usually "more than it needs to", and fixing that is worth more than the edge layer.

In both cases the honest advice is the same. The platform's existing caching model is the thing to understand first, and an edge layer that fights it will lose. The edge is where you put decisions the platform cannot make early enough — country, experiment arm, bot policy — not a second rendering system.

14. Measuring Whether Any of It Worked

Personalisation projects are unusually prone to being declared a success on the basis of nothing. Three measurements make that harder.

Cache hit ratio for HTML, broken down by variant. Not the combined figure, which is dominated by images and flatters everything. A variant with a hit ratio far below the others is one whose population is too small to keep it warm, and it should be merged into another.

# Which variants exist, and how often each is a hit. From CDN logs.
zcat cdn-*.log.gz \
  | awk '$7 ~ /^\/_v\// {split($7,a,"/"); print a[3]"/"a[4], $NF}' \
  | sort | uniq -c | sort -rn | head -20
# Any bucket with under ~2% of traffic is not earning its cache entry.

Time to first byte at p75, split by variant and by whether the visitor was personalised. If personalised visitors are materially slower, personalisation is costing you the thing it was meant to win.

A guardrail metric alongside the success metric. A banner personalisation that lifts click-through on the banner while reducing overall conversion is a loss, and the banner metric will not tell you. Pick the guardrail before the test runs.

And instrument the edge decision itself, so a support ticket can be diagnosed. Emitting the variant and the PoP as response headers costs nothing and turns "the site showed me the wrong price" from a two-day investigation into a two-minute one.

res.headers.set('x-variant', `${country}.${seg}.${arm}`);
res.headers.set('x-edge-colo', request.cf?.colo ?? 'n/a');
res.headers.set('x-cache-key-hint', cacheKeyPath);

15. Worked Example: Sportswear, Four Markets, Two Segments

Back to the retailer from the opening, because the rebuild is the useful part.

Where they started. Vary: Cookie on all HTML. 12% hit ratio. p75 TTFB 840ms. Personalisation consisted of a hero banner keyed to browse affinity across eleven categories, plus nearest-store stock on the product page.

First decision: cut the segments from eleven to three. The eleven came from their category taxonomy, which is a merchandising artefact rather than a behavioural one. Looking at the actual data, browse behaviour clustered into roughly three groups — running, gym and lifestyle — and the eleven-way split was mostly noise. Nobody in marketing objected once they saw that seven of the eleven segments had under 3% of traffic each.

Second decision: country in, city out. Four markets, in the cache key. Nearest-store logic moved to a client-side call against a store-locator endpoint, with the store name rendered into a reserved slot so there was no layout shift. This also fixed a standing complaint from customers in Manchester being shown a Liverpool store.

Third decision: hero banner as a variant document, product recommendations client-side. The banner is above the fold and a flash there is unacceptable, so it justifies cache cardinality. Recommendations are below the fold and do not.

Final cardinality: four countries times three segments equals twelve variants for the homepage and category pages. Product pages vary on country only — four — because the affinity segment did not change anything on a product page, which nobody had questioned before we counted.

Implementation. Middleware normalises country and segment, validates both against allowlists, and rewrites to /_v/{country}/{seg}/…. Query strings are stripped of tracking parameters and the remainder sorted before the key is computed. The segment cookie is written by a nightly job into their CDP and synced to a first-party cookie on login, gated on consent.

Results after five weeks. HTML hit ratio 88% — slightly below the 91% they had before personalisation, which is the honest cost of twelve variants and was accepted knowingly. p75 TTFB 71ms. Origin request volume returned to roughly its pre-launch level. The banner personalisation itself produced a 2.3% lift in category page click-through and, on the guardrail metric, no detectable change in overall conversion, which is a much less exciting result than the original launch had claimed and is the one I believe.

What went wrong. Two things.

We initially put the consent state into the cache key as a fourth dimension, doubling cardinality to twenty-four. It seemed correct: consented and non-consented visitors see different pages. It was unnecessary — a non-consenting visitor gets the default segment, which is already a variant, so the consent state was fully determined by the segment value. Removing it took cardinality back to twelve and the hit ratio up about four points. Look for dimensions that are functions of other dimensions; there is usually one.

The second was worse. For about nine days, the query-parameter stripping was applied before a check for pagination, so /category/running?page=3 and /category/running shared a cache key. Visitors clicking to page three got page one, intermittently, depending on which had been cached at that PoP. It was intermittent, which is why it took nine days — it never reproduced for the team, whose PoP had the right entry cached. The lesson I took: an allowlist of parameters that participate in the key is safer than a denylist of ones that do not, because the failure mode of forgetting an entry is a stale extra cache entry rather than serving the wrong content.

// Allowlist, not denylist. Forgetting to allow a parameter costs a
// duplicate cache entry. Forgetting to deny one serves wrong content.
const KEY_PARAMS = ['page', 'sort', 'colour', 'size'];

function normaliseUrl(url) {
  const kept = new URLSearchParams();
  for (const p of KEY_PARAMS) {
    const v = url.searchParams.get(p);
    if (v) kept.set(p, v);
  }
  kept.sort();                       // ?size=9&colour=black === ?colour=black&size=9
  const s = kept.toString();
  return url.pathname + (s ? `?${s}` : '');
}

16. Where Personalisation Earns Its Keep, and Where It Does Not

An opinionated list, from projects where we measured rather than assumed.

Worth it, reliably. Market, currency and language. Returning-visitor continuity — the basket, recently viewed, the store they last chose. Logged-in state in the header. These are not really personalisation so much as not being forgetful, and customers notice their absence far more than they notice their presence.

Worth it, sometimes. Homepage hero by broad affinity, if you have three segments rather than eleven and enough traffic to keep them warm. Geo-targeted delivery messaging, which is cheap and genuinely useful. Post-purchase cross-sell, which has real signal behind it.

Rarely worth it. Personalised category ordering, which fragments the cache heavily for a lift that in my experience does not survive a guardrail metric. Individually personalised product recommendations above the fold, where the latency cost usually exceeds the relevance gain. Anything requiring a per-user cache entry.

Actively harmful. Personalised pricing for anonymous visitors, which is a trust catastrophe when discovered and it is always discovered. Hard geo-redirects. Anything that changes on every visit, because customers navigate by memory of where things were and moving them reads as a broken site rather than a helpful one.

The general finding across these projects is that the ceiling on personalisation lift is lower than vendors suggest and the floor on the performance cost is higher. A 2–3% lift on a secondary metric is a realistic good outcome. If that lift comes with 200ms of added TTFB, you have very likely lost more than you gained, because the relationship between latency and conversion is one of the better-established things in this field.

17. Questions I Get Asked

"Can I personalise without hurting cache hit ratio at all?" Only with hole punching or client-side hydration, where the cached document is identical for everyone. Any variant-document approach costs cardinality; the goal is to spend it deliberately on the few dimensions that must be correct in the HTML.

"How many segments is too many?" More than eight variants per page and I want a specific argument. Above sixteen I would want to see the traffic distribution demonstrating each bucket gets enough volume to stay cached. A segment with 2% of traffic is a segment whose pages are always cold.

"Is Vary: Cookie ever acceptable?" On an API response with a small, controlled cookie set, occasionally. On HTML at a public CDN, no. If you need cookie-based variation, normalise the cookie into a token and put the token in the cache key.

"Should logged-in customers get cached pages?" The document, yes, in most cases. The parts that are theirs specifically — name, basket, contract prices, order history — should be hole-punched or fetched client-side. A logged-in customer on a product page is looking at the same product as everyone else.

"What about ESI, is it still relevant?" On Fastly and Akamai it works well and is a legitimate choice, particularly if you are already on VCL. It is a mature answer to the hole-punching problem with independent TTLs per fragment. Cloudflare has no ESI equivalent, so on Workers you are doing streaming assembly yourself. I would not introduce ESI to a stack that does not have it, but I would not rip it out either.

"How do I stop the personalisation flashing in?" Reserve the space so there is no layout shift, render a skeleton rather than a default value, and if the value is one the customer would notice changing — a price, above all — put it in the document instead of fetching it. The flash is a symptom of having chosen the wrong pattern for that element.

"Does personalisation hurt Core Web Vitals?" It can, through two mechanisms: lower cache hit ratio pushing TTFB up, and late-arriving content causing layout shift. Both are avoidable. Neither is avoided by default.

"Our vendor says their tag handles all this." A client-side personalisation tag that rewrites the DOM after load will cost you LCP and CLS, and the rewrite is not indexed. It is the fastest thing to deploy and the most expensive to live with. If it is already installed, measure the CLS contribution before defending it.

18. Where I Would Start

In order, and the first two are non-negotiable.

Measure your current HTML cache hit ratio, separately from assets. If it is already poor before you personalise anything, find out why first — it is usually query-string fragmentation or an accidental no-store that somebody set during a debugging session in 2019 and never reverted. I have found that exact thing twice.

Write down the multiplication. Every dimension you intend to vary on, multiplied out. If the number is above eight, cut dimensions until it is not, and be specific about what each surviving dimension buys. This conversation is uncomfortable and it is the single most valuable hour in the project.

Then pick the pattern per element rather than for the page. Currency in the document. Basket count hole-punched. Recommendations client-side. Most bad personalisation architectures come from choosing one mechanism and applying it to everything.

Normalise before you key. Strip tracking parameters, sort the survivors, clamp the segment to an allowlist, and validate every client-controlled value. Do this before you build any personalisation logic, because it is the thing that makes the cache work at all and it improves your numbers even with zero variants.

Then ship one dimension, on one page template, and measure for two weeks. Country on the homepage is a good first move: low cardinality, obvious value, easy to reverse. Resist the pressure to launch six segments across the whole site simultaneously, because when the hit ratio moves you will not know which decision caused it.

The thing I would leave you with is the framing that took me too long to arrive at. Personalisation is not a feature you add to a page. It is a decision about how many distinct pages you are willing to have, and every stakeholder request translates into a multiplication that somebody has to pay for in cache misses, origin load and latency. Make that multiplication visible early and the design mostly writes itself. Hide it, and you find out on a Thursday when the hit ratio has fallen to 12%.

Suggested & Related Reading

Explore related engineering guides from Kenneth D'Silva: