Edge Caching and Delivery for Islands
An islands page has three kinds of bytes with three different lifetimes: a document that changes when content changes, module chunks that change when code changes, and personalised fragments that change per visitor. Treating them as one thing is why so many otherwise well-optimised sites still wait on the origin for every request. This guide sets out a cache policy per class, shows how cache keys fragment without anyone noticing, and covers the proxy behaviour that silently converts a carefully streamed response into a buffered one β the failure that makes streaming SSR look useless in production while working perfectly in development.
Concept Definition & Scope
Edge delivery for islands is the practice of making the shared parts of a page free to serve while keeping the per-visitor parts correct. It succeeds when the vast majority of requests never reach the origin, the visitor still receives progressive HTML, and nothing personal is ever stored in a shared cache.
The three properties fight each other in specific ways. Personalisation pushes toward per-request rendering, which kills caching. Caching pushes toward complete responses, which can kill streaming. And streaming pushes toward long-lived connections, which complicate cache storage. Every recommendation below is a way of getting two of the three without giving up the third.
Out of scope: the rendering-mode decision itself, which is covered in rendering modes for islands, and the client-side payload work in reducing JavaScript payload in islands. This guide starts once those decisions are made and the bytes need to reach a browser quickly.
Technical Mechanics
Cache keys are where most of the value is won or lost, and the default key β full URL plus a Vary header β is almost never the right one.
// Edge worker: normalise the key before lookup, and set policy per class.
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// 1. Immutable, content-hashed island modules: answer from cache forever.
if (url.pathname.startsWith('/js/islands/')) {
const res = await fetch(request);
return new Response(res.body, {
headers: { ...res.headers, 'cache-control': 'public, max-age=31536000, immutable' },
});
}
// 2. Personalised fragments: never store, never share.
if (url.pathname.startsWith('/_fragment/')) {
const res = await fetch(request);
return new Response(res.body, {
headers: { ...res.headers, 'cache-control': 'private, no-store' },
});
}
// 3. Documents: strip parameters that do not change the response, so a
// campaign click and an organic visit share one cache entry.
const KEEP = new Set(['page', 'sort', 'variant']);
for (const key of [...url.searchParams.keys()]) {
if (!KEEP.has(key)) url.searchParams.delete(key);
}
url.searchParams.sort(); // order must not fragment the key
const cacheKey = new Request(url.toString(), { method: 'GET' });
const cache = caches.default;
const hit = await cache.match(cacheKey);
if (hit) return hit; // the common path: no origin work
const res = await fetch(request);
const cached = new Response(res.body, res);
// Serve stale for a minute past expiry while a background fetch refreshes,
// so an expiring entry never makes one unlucky visitor wait for the origin.
cached.headers.set('cache-control', 'public, s-maxage=300, stale-while-revalidate=60');
ctx.waitUntil(cache.put(cacheKey, cached.clone()));
return cached;
},
};
Two lines in that worker do most of the work. The parameter allow-list turns thousands of distinct keys back into one, and stale-while-revalidate removes the latency cliff at expiry. Everything else is bookkeeping.
Comparison: Policy per Asset Class
| Asset | Lifetime | Shared? | Revalidation | Failure if wrong |
|---|---|---|---|---|
| Document | Minutes | Yes | Background, on expiry | Stale content, or origin overload |
| Island chunk | One year, immutable | Yes | Never | Visitors run old code after a deploy |
| Island manifest | Seconds to a minute | Yes | Every request or nearly | Names resolve to deleted chunks β islands silently fail |
| Personal fragment | None | No | Not applicable | One visitor sees anotherβs data |
| Static assets | One year, immutable | Yes | Never | Same as chunks |
The manifest row deserves emphasis because it is the one people get wrong after doing everything else right. If component names resolve to hashed URLs through a JSON file, and that file is cached aggressively alongside the chunks, then after a deploy a returning visitor holds a manifest pointing at modules that no longer exist. The islands fail to import and the page is quietly non-interactive β with no error the visitor can report beyond βthe button does nothingβ.
Step-by-Step Integration Pattern
-
Inventory the routes and classify each one. Fully shared, shared with a personalised hole, or fully personal. Only the first two can be cached, and the second is the most common in practice.
-
Normalise the cache key before anything else. Strip tracking parameters, sort the survivors, and decide explicitly which headers may vary the response. Every
Varyheader multiplies your cache entries;Vary: Cookieon a document effectively disables caching. -
Set the three policies from the table above and confirm each with a request. The confirmation matters: framework defaults, host defaults and CDN defaults all interact, and the header you set is not always the header that arrives.
-
Add stale-while-revalidate to documents with a window shorter than the freshness window. This is the single highest-value line in the configuration for a site with spiky traffic.
-
Make the personalised fragment as small as possible. It cannot be cached, so every byte in it is paid per request. A greeting and a basket count is right; a rendered recommendation carousel is not β send its data and let the island render it.
-
Verify streaming survives the whole chain. Disable buffering for HTML responses in every proxy between the origin and the visitor, then measure byte arrival times rather than reading configuration files.
Measurement & Validation
# Does the shell really arrive before the slow fragment, through the CDN?
# --no-buffer prints bytes as they arrive; the timestamps are the evidence.
curl --no-buffer -s -w '\n[total %{time_total}s ttfb %{time_starttransfer}s]\n' \
https://example.test/product/42 | ts '%.s' | head -20
# Is the document actually being cached, and by which layer?
for i in 1 2 3; do
curl -s -o /dev/null -D - https://example.test/product/42 \
| grep -iE 'cf-cache-status|x-cache|age:|cache-control'
echo '---'
done
Three numbers belong on the dashboard. Hit ratio per route, because an average hides the one route that fragments its key. Age at delivery, which tells you how stale the typical hit actually is β a distribution skewed toward the maximum means the window is longer than the content needs. And origin request rate, which should be roughly traffic divided by the freshness window, not proportional to traffic; if it scales with traffic, something is bypassing the cache. The measurement discipline is the same one described in measuring hydration and interactivity metrics: record the number continuously, alert on the trend, and keep the lab check for regressions.
Purging, Deploys and the Order of Operations
Cache configuration is mostly a one-time job; cache invalidation is a permanent operational concern, and the order in which things happen during a deploy decides whether visitors see a broken page for thirty seconds.
The safe order is: upload the new immutable assets first, then switch the origin to the new build, then purge only the document cache. Because the assets are content-hashed, the new ones can sit alongside the old ones indefinitely, so a visitor holding an old document can still fetch the modules it references. Purging assets during a deploy is not only unnecessary, it is actively harmful β it evicts entries that were still correct and forces a cold fetch for every returning visitor.
The document purge should be targeted rather than global. A full purge of every document on every deploy converts each release into a traffic spike at the origin, which is exactly the load the cache exists to prevent. Purge by tag where the platform supports it: tag documents with the content ids they render, and purge those tags when the content changes rather than when code changes. A code deploy that does not change rendered output does not need a document purge at all.
Finally, keep a written answer to the question βan editor fixed a typo β what happens?β A revalidation window answers it for pages that receive traffic; a tag purge answers it immediately; nothing answers it for a static build without a deploy. Whichever it is, the content team should know the number, because βwhy is my change not live?β is otherwise a support conversation that repeats forever. The upstream half of this decision β what may be cached at all β belongs to rendering modes for islands.
Failure Modes
1. Vary: Cookie on a cacheable document. Every visitor has a different cookie, so every visitor gets a unique cache entry and the hit ratio collapses to near zero while every dashboard still says caching is enabled. Fix: keep the session out of the document entirely β the personalisation line again β and never vary a shared document on a per-visitor header.
2. An immutable manifest. Described above; the symptom is a non-interactive page for returning visitors after a deploy, resolving itself when they hard-refresh. Fix: short lifetime on the manifest, immutable on everything it points at.
3. A proxy that buffers HTML. Streaming works locally, disappears in production, and the configuration looks correct because the setting lives in a different layer than the one you checked. Fix: measure byte arrival through the real chain with the curl command above; if time to first byte equals total time, something is buffering.
4. Cache-busting query parameters added by well-meaning tooling. An analytics wrapper appends a unique parameter to internal links; every internal navigation becomes a cache miss. Symptom: excellent hit ratio for entry traffic and poor hit ratio for second page views. Fix: strip in the key normaliser and, better, stop adding them.
Frequently Asked Questions
Can a streamed HTML response be cached at the edge?
Yes, and most modern edge platforms do it correctly: the response is buffered into the cache while simultaneously being streamed to the first requester, so that visitor still gets progressive delivery and everyone afterwards gets a cache hit. The failure case is an older proxy that buffers the whole response before forwarding any of it, which preserves caching and destroys streaming. Test with a request that shows byte arrival times rather than trusting the configuration.
Why does my cache hit ratio collapse when traffic comes from ads?
Because campaign parameters are part of the URL and therefore part of the default cache key, so every click creates a unique key and a fresh origin request. The fix is to normalise the key by stripping tracking parameters before lookup, keeping only the parameters that genuinely change the response. This one change frequently moves a hit ratio from under twenty per cent to over ninety on campaign-heavy sites.
How long should island module chunks be cached?
A year, marked immutable, provided the filenames carry a content hash. The hash makes the URL a permanent name for exactly those bytes, so revalidation is never needed and a deploy simply introduces new names. The one thing that must not be immutable is any manifest that maps component names to those hashed URLs β that file has to be short-lived or the browser will keep resolving names to modules a deploy has removed.
Is stale-while-revalidate risky for a page with prices?
It is a deliberate trade rather than a risk, and the size of the trade is exactly the stale window you configure. A visitor can see a price up to that window old. For most catalogues a window of a minute or two is invisible and removes every origin-latency spike. When a stale price would be a commitment you cannot honour, keep the price out of the cached document and render it in a per-request fragment instead, which is the same personalisation line described in the rendering-modes guide.
Related
- Caching Streamed HTML with Stale-While-Revalidate β the configuration and the measurement that proves it works.
- Splitting Cache Keys for Personalised Islands β keeping a document shared while one region is not.
- Rendering Modes for Islands β the upstream decision that determines what can be cached at all.
- Reducing JavaScript Payload in Islands Apps β fewer and smaller chunks to cache in the first place.
- Next.js App Router Streaming Patterns β the streaming behaviour a proxy must not flatten.
β Back to Islands Performance Optimization