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
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
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.
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.
Related
- Caching Streamed HTML with Stale-While-Revalidate β the freshness half of the same configuration.
- Rendering Modes for Islands β deciding what belongs above the personalisation line.
- Using Astro Server Islands for Personalised Content β one framework implementation of the personal hole.
β Back to Edge Caching and Delivery for Islands