Measuring the Interactivity Ratio to Scope Islands

Deciding where an island should begin and end is usually done by feel, and feel is why so many islands implementations either over-hydrate a mostly static page or fragment it into dozens of tiny islands that each cost a dynamic import. The when to use islands versus full hydration decision becomes far sharper when it rests on a measured number: the interactivity ratio, the fraction of the page’s surface that genuinely needs client JavaScript. This guide computes that ratio two ways, maps it to threshold bands, and uses it to draw island boundaries you can defend with data.

Prerequisites


Interactivity Ratio and Threshold Bands Left: a page rectangle with two shaded interactive blocks over a mostly static background, showing interactive area over total area. Right: a horizontal ratio scale from 0 to 100 percent divided into three bands — islands under 30 percent, hybrid 30 to 60 percent, and full hydration over 60 percent. PAGE SURFACE static content (no JS) interactive: search interactive: cart ratio = Σ interactive area / total area THRESHOLD BANDS < 30% Islands 30–60% Hybrid (few large islands) > 60% Full / server-tree hydration Also check component count low area + high count → over-fragmentation risk Group contiguous interactive nodes into one island fewer imports = lower TBT

Implementation Steps

Step 1 — Audit interactive components

Goal: Produce an explicit list of every component that requires client JavaScript, separating true interactivity from static content that merely looks rich.

Walk the page and classify each region. A component is interactive only if it needs event handlers, reactive state, or a client-only effect — not merely because it contains a link or an image.

// DevTools console: list candidate-interactive elements and tag their owner.
// Static links and images are deliberately excluded — they need no hydration.
const sel = 'button, input, select, textarea, [contenteditable], ' +
  '[role="tab"], [role="switch"], [role="slider"], [data-interactive]';
const found = [...document.querySelectorAll(sel)].map((el) => ({
  tag: el.tagName.toLowerCase(),
  // Nearest labelled ancestor helps you name the eventual island.
  island: el.closest('[data-island-id]')?.dataset.islandId ?? '(unassigned)',
}));
console.table(found);

Expected output: A table of interactive nodes with an island column. Many rows tagged (unassigned) mean the page has interactivity not yet scoped into islands.

Step 2 — Compute the interactivity ratio

Goal: Turn the audit into a single number by area, and a second number by count.

// Area-weighted ratio over the full document (not just the viewport),
// plus the raw interactive component count for over-fragmentation checks.
const sel = 'button, input, select, textarea, [contenteditable], ' +
  '[role="tab"], [role="switch"], [role="slider"], [data-interactive]';

const docArea = document.documentElement.scrollWidth *
                document.documentElement.scrollHeight;

let interactiveArea = 0;
const nodes = document.querySelectorAll(sel);
for (const el of nodes) {
  const r = el.getBoundingClientRect();
  if (r.width > 0 && r.height > 0) interactiveArea += r.width * r.height;
}

const areaRatio = interactiveArea / docArea;
console.log(`area ratio   ≈ ${(areaRatio * 100).toFixed(1)}%`);
console.log(`interactive components: ${nodes.length}`);

Expected output: Two numbers, e.g. area ratio ≈ 18.6% and interactive components: 7. Record both — the ratio drives the band choice, the count guards against fragmentation.

Step 3 — Apply threshold bands

Goal: Map the measured ratio to a boundary strategy.

Area ratio Component count Recommendation
< 30% any Islands — scope each interactive zone; the static shell dominates and ships no runtime
30–60% low (≤ ~8) Hybrid — a few larger islands; keep the shell static, hydrate the dense zones
30–60% high (> ~8) Consolidate first — group contiguous nodes before deciding, or the import count erodes the win
> 60% any Full or server-tree hydration — orchestration overhead of many islands no longer pays for itself

A low area ratio paired with a high component count is the classic over-fragmentation trap: the page is mostly static, yet you are about to create a dozen islands. Consolidate before scoping.

Expected output: A single recommendation string for the page, e.g. 18.6% / 7 components → Islands, scope 2–3 clusters.

Step 4 — Draw island boundaries

Goal: Group contiguous interactive nodes into as few islands as the interaction model allows.

---
// Boundaries follow the audit: the search box and its results form ONE island,
// not three (input + button + list). Fewer islands = fewer dynamic imports.
import SearchIsland from '../components/SearchIsland.jsx';
import CartIsland from '../components/CartIsland.jsx';
---
<main>
  <article>{/* static content — the ~80% that needs no JS */}</article>

  
  

  
  
</main>

Expected output: A boundary map with the fewest islands that still keeps each interaction self-contained — typically far fewer islands than the raw interactive-node count.


Verification

  1. Ratio matches the build. Re-run the Step 2 script on the built page. The measured ratio should match your planning sketch within a few points; a large drift means the surface was more or less interactive than assumed and the band may change.
  2. JS-at-load budget. In the Network panel, confirm transferred JavaScript scales with the interactive area, not the total page. A static-dominant page in the islands band should show single-digit-to-low kilobytes.
  3. Total Blocking Time. Record a 4× CPU-throttled trace and confirm TBT did not climb after scoping — a rise signals over-fragmentation from too many islands. Compare against the framework baselines in comparing hydration strategies across Next.js and Astro.
  4. Boundary sanity. Confirm each island is self-contained — no island depends on another’s runtime state through a shared mutable store, which would couple their hydration and inflate cost.

Troubleshooting

The area ratio is low but Total Blocking Time went up after scoping

Root cause: Over-fragmentation. A low area ratio with a high component count was split into many small islands, each triggering its own dynamic import and hydration task, so orchestration overhead exceeded the JS you saved.

Fix: Consolidate contiguous interactive nodes into a single island per interaction zone. Re-measure the component count after grouping; aim for the fewest islands that keep each interaction independent.

The ratio looks high, but most "interactive" nodes are static links or decorative elements

Root cause: The selector counted elements that do not actually need client JavaScript — anchor links, hover-only styling, or CSS-driven disclosure widgets that work without hydration.

Fix: Tighten the audit selector to true interactivity: event handlers, reactive state, client-only effects. Exclude <a> navigation and pure-CSS interactions. Re-run Step 2; the corrected ratio usually drops into a lower band.

The measured ratio swings between page states (logged out vs logged in)

Root cause: Personalised or authenticated views expose interactive controls that anonymous views hide, so a single measurement is not representative.

Fix: Measure each significant state and scope for the worst case, or scope per state if your framework supports conditional islands. Pair this with when to use islands versus full hydration to decide whether the interactive state warrants a different strategy entirely.


← Back to When to Use Islands vs Full Hydration