Splitting Cache Keys for Personalised Islands

A cache key is a promise that two requests deserve the same bytes. Break it too finely and every visitor gets their own copy; break it too coarsely and someone sees another visitor’s name. This walkthrough gets both right at once β€” a shared document with a personal hole β€” as the key-management half of edge caching and delivery for islands.

Prerequisites

Implementation Steps

Where the Entries Multiply A single route multiplies into cache entries through four inputs. Campaign parameters create one entry per click. Analytics parameters create one per internal navigation. Varying on cookie creates one per visitor. Varying on user agent creates one per browser build. The intended variants β€” currency and language β€” are a small number by comparison. One route, and what multiplies it campaign parameters β€” one entry per click, effectively unbounded analytics parameters on internal links β€” one entry per navigation Vary: Cookie β€” one entry per visitor, which is no cache at all currency β€” 4 intended variants language β€” 3 intended variants intended: 12 entries. Actual, before normalisation: tens of thousands.

Step 1 β€” Normalise the key before lookup

const KEEP = new Set(['page', 'sort', 'variant']);   // parameters that change output
const COHORT_HEADERS = ['x-currency', 'x-language']; // small, enumerated dimensions

function cacheKeyFor(request) {
  const url = new URL(request.url);
  for (const k of [...url.searchParams.keys()]) {
    if (!KEEP.has(k)) url.searchParams.delete(k);    // campaign and analytics noise
  }
  url.searchParams.sort();                           // order must not fragment
  // Cohort dimensions become part of the path rather than a Vary header, so the
  // number of entries is visible and boundable rather than implicit.
  const cohort = COHORT_HEADERS.map((h) => request.headers.get(h) || 'default').join('-');
  url.pathname = `/__c/${cohort}${url.pathname}`;
  return new Request(url.toString(), { method: 'GET' });
}

Folding cohorts into the key path rather than using Vary has one practical advantage: you can enumerate and purge them. A Vary header is opaque, and a mistake in it is invisible until the hit ratio collapses.

Step 2 β€” Move the personal region out of the document

<!-- Shared document: one entry for everyone in the cohort. -->
<section data-island="personal-offer" data-src="/_fragment/personal-offer">
  <!-- Server-rendered neutral state, sized to the final content. -->
  <p>Sign in to see your saved items.</p>
</section>

The fragment endpoint responds with cache-control: private, no-store, reads the session, and returns a few hundred bytes. The island fetches it on activation, or the platform composes it server-side for visitors without JavaScript β€” the pattern in using Astro server islands for personalised content.

Step 3 β€” Keep cohort cardinality boundable

Every cohort dimension multiplies entries. Two dimensions with four and three values give twelve copies of every page β€” acceptable. Adding a device class of three takes it to thirty-six, and adding a segment of ten takes it to three hundred and sixty, at which point rarely visited pages are effectively uncached because no entry is requested often enough to stay warm. Treat each new dimension as a decision with a number attached.

Verification

Hit Ratio Before and After Normalisation Three routes measured before and after stripping tracking parameters. The landing page, which receives most campaign traffic, moves from eighteen per cent to ninety-four. A category page moves from sixty-one to ninety-six. A rarely visited detail page barely moves, because its misses come from low traffic rather than from fragmentation. Cache hit ratio, before and after landing 18% β†’ 94% category 61% β†’ 96% rare detail page 33% β†’ 38% β€” traffic-bound, not fragmentation-bound

Three verifications, in order of how often they catch something.

Entries per route against intended variants. Query the cache for the number of distinct entries on one route and compare it to the product of your cohort dimensions. An order-of-magnitude gap is fragmentation, and the request URLs in edge logs will name the parameter responsible.

The two-session diff on the shared document. Byte-identical documents for two different sessions, with only the fragment endpoint differing. This is the safety check: a shared key with per-visitor content is a data leak, not a performance issue.

Hit ratio by traffic source. Split the ratio by referrer class. If organic traffic hits well and paid traffic does not, campaign parameters are still in the key somewhere β€” often added by a redirect you do not own.

Personalisation Without Fragmentation

The general principle is worth stating plainly, because it resolves most arguments about caching personalised pages: cache what is shared, request what is personal, and never let the second contaminate the first.

The temptation is always to avoid the extra request. It costs a round trip, it needs its own error state, and it means the personal content is not present in the initial HTML. Those are real costs. Weigh them against the alternative, which is not β€œa slightly slower cached page” but β€œno cache at all” β€” because a document that varies per visitor cannot be shared by definition, so every request renders at the origin.

There is a middle path worth knowing about for pages where the personal part must be in the initial HTML: server-side composition at the edge, where the platform assembles a cached shell and an uncached fragment into one response before sending it. The visitor gets a single document with everything in it, the shell still comes from cache, and only the small fragment is rendered per request. It is more machinery than a client fetch and it is the right answer when the personal content is above the fold or must work without JavaScript.

Cohort Cardinality Multiplies Each cohort dimension multiplies stored entries. Two dimensions of four and three values give twelve copies per page; adding a device class of three takes it to thirty-six, and a ten-value segment takes it to three hundred and sixty. Every dimension multiplies, so count before adding one currency Γ— language 12 entries per page β€” fine + device class 36 β€” defensible if the markup genuinely differs + a 10-value segment 360 β€” rarely visited pages are now effectively uncached

Troubleshooting

When is varying the cache key legitimate?

When the response genuinely differs and the number of variants is small and enumerable β€” currency, country, language, or a device class if you serve materially different markup. Each of those multiplies your entries by its cardinality, so two dimensions with five values each is twenty-five copies of every page. Anything with unbounded cardinality, such as a session or a user id, is not a cache dimension at all; it is a signal that the content belongs in a separate uncached request.

How do I personalise without varying the key at all?

Serve one shared document containing a placeholder, and fill the placeholder from a second request that is marked private and no-store. The document stays in the shared cache with a single entry, and the visitor pays one small extra round trip for the personal part. Because that request carries only the personal fragment, it is typically a few hundred bytes and can start as soon as the shell parses.

What is the fastest way to find unintended fragmentation?

Compare the number of cache entries for a route against the number of variants you intended. If a route has one logical variant and thousands of entries, something in the key is unbounded β€” almost always a query parameter appended by campaign tooling or an analytics wrapper. Sampling a few request URLs from the edge logs usually identifies the culprit within a minute.

← Back to Edge Caching and Delivery for Islands