Islands Performance Optimization & Core Web Vitals

Islands Architecture is adopted almost entirely for one reason: to make an interactive page fast without paying the full cost of a single-page application. But the architecture is a means, not a result. A site can ship a static shell and selectively hydrate a handful of components and still fail its Core Web Vitals if the wrong islands hydrate eagerly, if a single click handler runs a long task, or if a streamed chunk shifts layout beneath the fold. Performance engineers and SaaS founders need a measurement discipline that ties every architectural decision back to a field metric users actually feel — Largest Contentful Paint, Interaction to Next Paint, and Cumulative Layout Shift — and to the lab metrics that predict them, Time to Interactive and Total Blocking Time. This guide is the measurement and optimisation layer that sits on top of core Islands Architecture and hydration models: how to instrument islands, read the numbers correctly, reduce the JavaScript that reaches the browser, and benchmark one framework against another with a workflow you can trust.


Islands Performance — From Load Timeline to Core Web Vitals A horizontal timeline of an islands page load with vertical markers for first byte, First Contentful Paint, Largest Contentful Paint, and interaction. Above the timeline the load phases are shown; below, each phase maps to the metric it governs: LCP, TTI, TBT, CLS, and INP. first byte interactive session Stream static shell Paint LCP content Selective island hydration User interactions LCP TTI first input LCP shell + resource order TBT · TTI hydration long tasks INP event handler latency CLS — accrues across the whole load whenever an island mounts without reserved space reserve min-height on every island container before it hydrates Load timeline → the metric each phase governs Optimising the wrong phase for the wrong metric is the most common wasted effort in islands tuning.

Architectural Foundations

Performance work on islands fails when the team optimises a number that does not map to what users feel. Before any tuning, pin down the vocabulary precisely, because each metric is governed by a different phase of the load and responds to a different lever.

  • Largest Contentful Paint (LCP): The render time of the largest image or text block visible in the viewport. In an islands app LCP is almost always part of the static shell, so it is governed by streaming order, resource priority, and how much render-blocking work sits ahead of it — not by hydration. Target: ≤ 2.5s at the field p75.
  • Interaction to Next Paint (INP): The metric that replaced First Input Delay as a Core Web Vital. It reports a high percentile of the latency between a user interaction and the next paint, across the entire session. For islands this is the metric to protect, because a single island with a heavy click handler defines it. Target: ≤ 200ms.
  • Cumulative Layout Shift (CLS): The sum of unexpected layout shift scores. In islands apps CLS is overwhelmingly caused by a streamed chunk or a mounting island expanding beyond its reserved height. Target: ≤ 0.1.
  • Time to Interactive (TTI): A lab metric — the first moment the main thread stays quiet enough (no task > 50ms for 5s) to respond to input reliably. It is a proxy for when hydration has settled.
  • Total Blocking Time (TBT): The sum of the portion of every long task over 50ms between First Contentful Paint and TTI. TBT is the lab metric that best predicts field INP, and it is the number selective hydration is designed to shrink.
  • Hydration cost: The wall-clock main-thread time spent activating islands. Measured directly as a hydration delta — the interval between DOMContentLoaded and the last island-activation mark.
  • JavaScript payload: The bytes of script the browser must fetch, parse, compile, and execute. Payload matters because parse/compile scales with size, but the metric that actually moves is execution time, which is why reducing JavaScript payload in islands is measured in main-thread milliseconds, not just kilobytes.

The core discipline is attribution: given a poor metric, know which phase produced it. LCP regressions live in the shell and the network waterfall. TBT and TTI regressions live in hydration. INP regressions live in a specific interaction handler. CLS regressions live in unreserved island containers. Every optimisation in this guide is filed under the phase it belongs to.

Execution Pipeline: Instrumenting the Load

An islands page produces four measurable moments — first byte, LCP, the hydration window, and each interaction — and every one of them can be captured from the browser without a third-party dependency. The pattern is to emit performance.mark entries at island boundaries on the client, and to read every Core Web Vital through the same PerformanceObserver surface so lab and field share one instrument.

// CLIENT — one instrumentation module loaded before island activation.
// It captures the hydration delta AND the three Core Web Vitals so a single
// RUM payload carries everything needed to attribute a regression.
const marks = [];

// 1. Each island calls performance.mark() when it starts and finishes mounting.
//    The scheduler in the core pipeline already emits 'island:hydrated:<id>';
//    here we also capture the *start* so per-island cost is attributable.
const hydrationObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name.startsWith('island:')) marks.push(entry);
  }
});
hydrationObserver.observe({ type: 'mark', buffered: true });

// 2. LCP — the last largestContentfulPaint entry before the first interaction
//    is the reported value. Keep the newest; it supersedes earlier candidates.
let lcp = 0;
new PerformanceObserver((list) => {
  const entries = list.getEntries();
  lcp = entries[entries.length - 1].startTime; // most recent candidate wins
}).observe({ type: 'largest-contentful-paint', buffered: true });

// 3. CLS — sum layout-shift entries that were not caused by recent input.
//    Unreserved island mounts show up here as input-independent shifts.
let cls = 0;
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) cls += entry.value;
  }
}).observe({ type: 'layout-shift', buffered: true });

// 4. Hydration delta — computed once the page goes idle.
function reportHydrationDelta() {
  const domReady = performance.getEntriesByType('navigation')[0].domContentLoadedEventEnd;
  const lastMark = marks.reduce((max, m) => Math.max(max, m.startTime), domReady);
  // The delta is the main-thread window islands spent becoming interactive.
  console.log(`[perf] hydration delta: ${(lastMark - domReady).toFixed(1)}ms`);
  console.log(`[perf] LCP: ${lcp.toFixed(0)}ms  CLS: ${cls.toFixed(3)}`);
}
requestIdleCallback(reportHydrationDelta, { timeout: 5000 });

The interaction side of the pipeline — INP — cannot be captured with marks, because it measures the browser’s own event-processing latency. It requires the event timing entry type, which exposes processingStart, processingEnd, and the presentation time. That measurement is the subject of measuring hydration and interactivity metrics, the guide that goes handler-by-handler through instrumenting interactivity. The key architectural point here is that INP is governed by whichever island runs the slowest handler, so partial hydration helps INP only if it also breaks long handlers into yielded work — deferring activation alone does not fix a slow click.

The Metric Reference Table

Every optimisation begins by choosing the correct instrument. The table below is the canonical mapping from metric to what it captures, the target you are held to, and the tool that measures it reliably.

Metric What it captures Target (p75) Primary tool
LCP Render time of the largest viewport element (shell content) ≤ 2.5s PerformanceObserver (largest-contentful-paint), Lighthouse
INP Worst-case interaction-to-paint latency across the session ≤ 200ms web-vitals onINP, PerformanceObserver (event)
CLS Sum of input-independent layout shift scores ≤ 0.1 PerformanceObserver (layout-shift), DevTools Layout Shift regions
TTI First 5s main-thread quiet window (lab proxy for interactivity) ≤ 3.8s Lighthouse (--throttling-method=devtools)
TBT Blocking portion of all long tasks between FCP and TTI ≤ 200ms Lighthouse, PerformanceObserver (longtask)
Hydration delta Wall-clock main-thread time to activate all islands ≤ 300ms performance.mark + PerformanceObserver
JS execution time Parse + compile + run cost of shipped island bundles ≤ 350ms DevTools Performance panel bottom-up, Coverage tab

TBT and INP are the two numbers to watch obsessively in an islands app. They are correlated but not identical: TBT is a lab number bounded to the load window, while INP is a field number that spans the whole session. A page can pass TBT and fail INP if a late interaction — a filter, a sort, a modal — triggers an expensive handler. Both are downstream of one root variable: main-thread execution time, which is why payload reduction and activation scheduling are the two highest-leverage optimisations.

Framework & Optimisation Landscape

The optimisation surface divides cleanly into three areas, each owned by a dedicated area of this guide.

Measuring first. Nothing should be tuned before it is measured under both lab and field conditions. Measuring hydration and interactivity metrics covers how to instrument the hydration delta, capture INP and TBT with PerformanceObserver, and reconcile lab traces against RUM so you never optimise a number that does not exist in the field. Its two procedures — measuring INP handler latency and profiling the hydration window in DevTools — are the entry points for any investigation.

Then reduce the payload. Once you know which island is expensive, the highest-leverage fix is to ship it less JavaScript. Reducing JavaScript payload in islands covers bundle analysis, splitting islands by route and by interaction, and trimming the runtime a framework injects per island. This is where parse/compile cost is attacked at the source, and where streaming SSR helps by deferring the delivery of below-the-fold island code entirely.

Then choose the right runtime. Framework choice sets the floor for how much JavaScript an island can cost. Framework performance benchmarks provides reproducible TTI and TBT workflows to compare Astro, Next.js, Qwik, and SolidStart on identical hardware. The headline difference is structural: Qwik’s resumable architecture executes near-zero JavaScript at load regardless of page complexity, so it starts from a different baseline than any hydration-based framework and often wins TBT outright while trading against serialization payload size.

Measurement Baselines

Concrete numbers matter more than principles when defending a performance budget. These baselines are what a healthy islands implementation looks like under a simulated 4G / CPU 4× throttle on a mid-range mobile profile.

  • Hydration delta < 300ms. Above this, too many islands are activating eagerly or a single island carries too much initialisation. Split it or move it to an idle trigger.
  • TBT < 200ms. Selective hydration should keep the sum of long-task blocking well under the SPA baseline of 300–800ms. If TBT stays high, hydration is not actually deferred — a client:load directive is probably applied where client:idle or client:visible belongs.
  • INP p75 < 200ms. Capture this in the field. Lab INP is optimistic because it exercises few interactions; real users hit the slow path.
// A minimal RUM emitter using the web-vitals library's attribution build.
// onINP fires with the specific element and event type responsible,
// which points straight at the island whose handler owns the regression.
import { onINP, onLCP, onCLS } from 'web-vitals/attribution';

function send(metric) {
  // metric.attribution.interactionTarget names the DOM node for INP —
  // map it back to an island id to know which bundle to profile next.
  navigator.sendBeacon('/rum', JSON.stringify({
    name: metric.name,
    value: metric.value,
    target: metric.attribution?.interactionTarget ?? metric.attribution?.element,
  }));
}

onINP(send, { reportAllChanges: false }); // p75 across the session
onLCP(send);
onCLS(send);

The single most useful habit is to always carry attribution. A bare INP value of 340ms tells you the page is slow; interactionTarget: "#search-island button" tells you exactly which bundle to profile in measuring hydration and interactivity metrics.

Decision Guidance

Optimisation effort is finite. Use the table below to route a given symptom to the intervention that actually addresses it, rather than reaching for the same fix regardless of cause.

Symptom (measured) Root phase Intervention
LCP > 2.5s, TBT normal Shell / network Prioritise the LCP resource, preload fonts, remove render-blocking CSS — not a hydration problem
TBT > 300ms at load Hydration scheduling Move non-critical islands to client:idle / client:visible; audit for stray client:load
INP p75 > 200ms, TBT fine Interaction handler Break the handler into yielded chunks; measure per how to measure INP
Hydration delta > 300ms Payload / eagerness Analyse and split the island bundle via reducing JavaScript payload in islands
CLS > 0.1 on load Unreserved island mount Set min-height / content-visibility with contain-intrinsic-size on island containers
High TBT no matter the tuning Framework floor Benchmark alternatives with framework performance benchmarks; consider resumability
Metrics good in lab, poor in field Device / third-party Reconcile with RUM; filter longtask attribution for third-party contenders

The strategic message for stakeholders: islands buy a large TBT/INP reduction, but they do not automatically fix LCP or CLS, and they do not fix a slow interaction handler. Each Core Web Vital has its own owner phase, and pretending one architecture fixes all three is how teams ship an islands app that is still slow where it counts.

Failure Modes & Anti-Patterns

1. Eager everything. The most common regression is applying an immediate activation directive (client:load, or a bare hydration with no scheduler) to islands that could wait. The symptom is a hydration delta and TBT that barely improve over the SPA baseline despite the static shell being fast. Fix: default every island to the cheapest trigger that satisfies UX — client:visible below the fold, client:idle for non-critical widgets — and reserve immediate activation for above-the-fold controls a user reaches within 100ms.

2. Optimising bytes instead of milliseconds. Teams celebrate a 40% bundle-size reduction and find TBT unchanged, because the island still runs a synchronous initialisation on mount. Payload correlates with parse/compile cost, but execution time is the number that blocks the main thread. Fix: profile execution in the DevTools bottom-up view and break long synchronous work into idle-scheduled chunks — size reduction is necessary but not sufficient.

3. INP measured only in the lab. Lighthouse exercises a page for a few seconds and rarely triggers the expensive interaction, so lab INP passes while field INP fails. The slow path is a sort, a filter, or a modal that only real users reach. Fix: capture INP with the web-vitals attribution build in production and route the reported interactionTarget back to a specific island for profiling.

4. Streaming CLS. A streamed island chunk paints a collapsed fallback, then the island mounts and expands, shifting everything below it. This is invisible in a warm lab run where the chunk arrives instantly but scores badly on a real 4G connection. Fix: reserve every island container’s eventual height with min-height or content-visibility: auto plus a contain-intrinsic-size hint before it hydrates.

5. No regression gate. A single well-tuned deploy decays as features accumulate, because nothing fails the build when hydration cost creeps up. Fix: commit a baseline of hydration delta and TBT, run the measurement in CI on every deploy, and fail on a +30ms hydration-delta or +50ms TBT regression before it reaches users.


← Back to Islands Architecture & Streaming SSR