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

What Survives a Reload Versus a Swap Two columns list the state present on a page before an update. After a full reload, typed text, open drawers, scroll position within a list and pending optimistic updates are all lost, and every island re-activates. After a targeted swap, only the changed content regions are replaced; island subtrees, focus, scroll and pending state all survive, and no island re-activates. The same content update, two implementations FULL RELOAD typed text — lost open filter drawer — closed scroll position in a list — reset pending optimistic update — dropped every island — re-activates cost: a full activation pass, plus the interruption TARGETED SWAP typed text — untouched open filter drawer — still open scroll position — preserved pending update — still in flight islands — never re-activate cost: one fetch and a handful of node replacements

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.

Every Region Takes One of Three Paths For each region in the incoming document the routine chooses one of three outcomes. Unchanged regions are skipped without touching the DOM. Changed static regions are replaced outright. Regions inside an activated island are never replaced; instead the new values are dispatched as an event the island handles, so its internal state and listeners survive. Three outcomes per region SKIP outerHTML identical no DOM work at all most regions, most updates REPLACE changed static markup replaceWith, one node no listeners to lose here NOTIFY inside an active island dispatch new values the island decides what to do The third path is the one that makes this worth doing — it is also the one implementations forget, and the symptom is dead buttons.

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.

Three Tests, Three Defects The typing test detects state destroyed by an over-broad swap. The listener test detects event handlers left bound to nodes removed from the document. The no-op test detects an update routine that replaces regions without comparing them, which loses state intermittently rather than always. TEST DETECTS type, then update state destroyed by replacing an island subtree click after update handlers bound to nodes no longer in the document update with no change a routine that swaps unconditionally

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.

← Back to Rendering Modes for Islands