Revalidating Cached Pages Without Rehydrating Islands
A cached page eventually goes stale in a tab that is still open. The naive response — reload — throws away everything the visitor has typed, opened or scrolled to, and on a page built from islands that is a heavier cost than it looks, because each island holds state the document does not. This guide covers the targeted alternative: fetch the new document, swap only what changed, and leave activated islands alone.
Prerequisites
Implementation Steps
Step 1 — Detect that the cached content moved
Poll a token endpoint on visibility change rather than on a timer. A tab in the background does not need fresh content; a tab the visitor has just returned to does. The endpoint returns a few bytes — a content hash or a monotonic version — and can itself be cached for a short window so the poll costs nothing at the origin.
// Compare a cheap token before fetching a whole document.
let currentVersion = document.documentElement.dataset.contentVersion;
async function checkForUpdate() {
if (document.visibilityState !== 'visible') return;
const res = await fetch('/_version/product/42', { cache: 'no-store' });
const { version } = await res.json();
if (version === currentVersion) return; // the common case: nothing to do
currentVersion = version;
await applyUpdate();
}
document.addEventListener('visibilitychange', checkForUpdate);
Step 2 — Fetch the new document and diff at the region level
Parse the response into a detached document and compare region by region, keyed by an identifier the server emits. Comparing outerHTML strings is precise enough and avoids a diffing library. Anything unchanged is skipped, which matters because the cheapest node replacement is the one you do not perform.
Step 3 — Skip island subtrees entirely
async function applyUpdate() {
const html = await fetch(location.href, { cache: 'reload' }).then((r) => r.text());
const next = new DOMParser().parseFromString(html, 'text/html');
for (const incoming of next.querySelectorAll('[data-region]')) {
const current = document.querySelector(`[data-region="${incoming.dataset.region}"]`);
if (!current || current.outerHTML === incoming.outerHTML) continue;
// An activated island owns its subtree — replacing it would destroy state
// and leave listeners bound to detached nodes.
if (current.closest('[data-island-state="ready"]')) {
// Deliver the change as data instead of markup.
current.dispatchEvent(new CustomEvent('content:update', {
bubbles: true,
detail: JSON.parse(incoming.dataset.payload || '{}'),
}));
continue;
}
current.replaceWith(incoming.cloneNode(true));
}
}
Step 4 — Restore focus and scroll around the swap
Record document.activeElement and its selection before replacing anything, then restore afterwards if the element survived. Scroll anchoring usually handles the viewport automatically when replaced regions keep their height, which is one more reason for the reserved-space discipline described in preventing layout shift with skeleton placeholders.
Verification
The typing test. Focus an input inside an island, type several characters, place the caret in the middle, then force an update. The text, the caret position and the focus must all survive. This single test catches almost every implementation error, because everything that breaks state breaks this.
The listener test. After an update, click a control inside an island. If nothing happens, the subtree was replaced and the listeners are bound to detached nodes — the exact failure the island guard exists to prevent.
The no-op test. Trigger an update when nothing changed and assert that zero nodes were replaced. An implementation that replaces regions unconditionally will pass the typing test on a fast machine and fail it under load, because the window in which state is lost is simply shorter.
When a Reload Is the Right Answer
The swap is not always worth building. Three situations make a reload the honest choice.
If the change is structural — a navigation redesign, a new section between existing ones, a layout that reflows — a region-level swap becomes a general-purpose DOM reconciler, which is a framework rather than forty lines. If the page has no islands holding meaningful state, there is nothing to protect and a reload costs the visitor a repaint they will barely notice. And if the update is rare enough that the code path will not be exercised — a content change once a week on a page visitors keep open for minutes — the swap is untested machinery on a critical path, which is worse than a reload that is guaranteed to work.
A middle option is worth knowing: reload, but restore. Persist the interactive state a visitor would miss — filter selections, an open drawer, a draft — into the URL or session storage, then reload and rehydrate from it. The visitor sees a flash, but nothing is lost, and the implementation is far smaller than a general swap. It suits pages where the state is small and enumerable, and it has the pleasant side effect of making that state survive a genuine navigation too.
Troubleshooting
Why not simply reload the page when cached content changes?
Because a reload destroys everything the visitor has done that is not in the URL: text typed into an island, an open filter drawer, scroll position within a virtualised list, and any optimistic state waiting on a response. On a content page a reload is often acceptable; on a page with real interactive regions it converts a background content update into a visible interruption. The fragment swap costs perhaps forty lines and removes the interruption entirely.
How does the client know a cached page has newer content?
Either it polls a lightweight endpoint that returns a version token, or the server pushes the token over an existing connection. Comparing tokens is cheap enough to do on visibility change, which covers the common case of a tab left open for an hour. Fetching the whole document to find out whether it changed defeats the purpose, so the token endpoint should return a few bytes and be cacheable for a short window of its own.
What happens if an island's props change during a refresh?
Push the new values into the island rather than re-rendering its markup. A custom event on the island container, or a write to the shared store it subscribes to, lets the component reconcile the change with whatever local state it holds. Replacing the props element in the DOM does nothing on its own, because the island read those props once at activation and will not read them again.
Related
- Choosing Between Static and Server Rendering Per Route — deciding the window this refresh responds to.
- Sharing State Across Islands — the store that receives pushed values during an update.
- Edge Caching and Delivery for Islands — where the stale document came from in the first place.
← Back to Rendering Modes for Islands