Measuring Hydration & Interactivity Metrics

You cannot optimise hydration you cannot see. The defining difficulty of islands performance is that the two moments engineers care about — when the page becomes interactive, and how fast it responds when a user actually interacts — are separate events, captured by different browser APIs, and easy to conflate. A page can show a hydration delta of 90ms and still post an INP of 400ms because one island’s click handler runs a long task the load-time measurement never touched. This guide is the instrumentation layer of islands performance optimization: how to measure hydration timing and interactivity precisely, with PerformanceObserver, the web-vitals library, and a RUM pipeline that carries enough attribution to point at the exact island responsible for a regression.

Two Measurement Windows — Hydration vs Interaction The upper track shows the hydration window from DOMContentLoaded to the last island mark, measured by performance marks and longtask entries producing the hydration delta and TBT. The lower track shows a later interaction, measured by an event timing entry decomposed into input delay, processing time, and presentation delay, producing INP. HYDRATION WINDOW — performance.mark + longtask DOMContentLoaded island A mount island B mount (long task) island C mount last mark hydration delta = last mark − DOMContentLoaded …user reads, then interacts later… INTERACTION WINDOW — event timing entry → INP pointerdown input delay processing (handler runs) presentation next paint INP = input delay + processing + presentation delay

Concept Definition & Scope

Measuring hydration and interactivity means capturing two distinct classes of number and never confusing them. The first class is load-time hydration cost: how long island activation blocks the main thread, expressed as the hydration delta and as the Total Blocking Time contributed by hydration long tasks. The second class is interaction responsiveness: how quickly an already-hydrated island responds to input, expressed as INP and, for legacy comparison, First Input Delay. This scope is the direct downstream of islands performance optimization, which sets the targets; here we build the instruments that produce the numbers those targets are checked against.

What this covers:

  • Emitting and reading performance.mark boundaries around island activation to compute a hydration delta.
  • Attributing Total Blocking Time to hydration with longtask timing entries.
  • Capturing INP and FID from event and first-input timing entries, and from the web-vitals library.
  • Shipping all of it to a RUM pipeline with attribution that names the responsible island.

What falls outside its scope:

Technical Mechanics

Emitting Island Boundaries

The hydration delta is only trustworthy if the marks are real activation boundaries, not approximations. Each framework exposes a lifecycle hook where the mark belongs. The pattern is identical: mark on entry, mark on the first committed paint of the island.

---
// components/SearchIsland.astro wrapper is server-only; the marks live in the
// framework component it hydrates. Astro fires a CustomEvent per island —
// 'astro:hydrate' — which is the cleanest boundary to observe globally.
---
<div id="search-island">
  
</div>

<script>
  // Global listener: every Astro island dispatches this on activation start.
  // No per-component edits needed — one observer covers the whole page.
  document.addEventListener('astro:hydrate', (e) => {
    const id = (e.target as HTMLElement)?.id ?? 'anon';
    performance.mark(`island:hydrated:${id}`);
  });
</script>
// React island (Next.js App Router). useEffect with empty deps fires once
// after hydrateRoot commits this boundary — the correct place to mark 'end'.
'use client';
import { useEffect } from 'react';

export function SearchWidget() {
  useEffect(() => {
    // Runs after event listeners are attached = island is interactive.
    performance.mark('island:hydrated:search');
  }, []); // empty deps — fires once at hydration commit, not on re-render
  return <input aria-label="Search" />;
}
// Qwik behaves differently: there is no hydration commit to mark because the
// resumable model attaches no listeners at load. Mark on first interaction
// instead — the moment the lazy handler symbol is actually fetched and run.
import { component$, useSignal, useOnDocument, $ } from '@builder.io/qwik';

export const SearchWidget = component$(() => {
  const q = useSignal('');
  // qidle fires when the Qwik loader has processed the document — the closest
  // analogue to 'hydration done' in a model that never hydrates eagerly.
  useOnDocument('qidle', $(() => performance.mark('island:ready:search')));
  return <input value={q.value} onInput$={(_, el) => (q.value = el.value)} />;
});

The Qwik case is the instructive one: because resumable architecture attaches no listeners at load, there is no hydration window to measure at all — its hydration delta is effectively zero and the meaningful number is the interaction latency of the first click, which is where INP measurement earns its keep.

Reading the Delta and Blocking

With marks emitted, one observer computes both the hydration delta and the hydration-attributed TBT.

// Register BEFORE island scripts run, with buffered:true so no early
// entry is dropped. This is the single measurement entry point.
const islandMarks = [];
let blockingFromHydration = 0;

new PerformanceObserver((list) => {
  for (const e of list.getEntries()) islandMarks.push(e);
}).observe({ type: 'mark', buffered: true });

// longtask entries expose the blocking work; sum the >50ms overage that
// overlaps the hydration window to get hydration's TBT contribution.
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    blockingFromHydration += Math.max(0, e.duration - 50); // TBT definition
  }
}).observe({ type: 'longtask', buffered: true });

Comparison: Which Instrument for Which Question

Choosing the wrong API produces numbers that look precise and mean nothing. This table maps the question to the instrument, what it actually measures, and the trap that most often corrupts it.

Question Instrument Measures Common trap
How long did activation block? performance.mark + PerformanceObserver('mark') Hydration delta (wall clock) Marking after the call, not after commit — captures scheduling only
How much did hydration block input? PerformanceObserver('longtask') TBT contribution Forgetting buffered:true — early tasks lost
How fast is the first interaction? PerformanceObserver('first-input') Legacy FID FID is deprecated; only useful for historical comparison
How fast is every interaction? web-vitals onINP / PerformanceObserver('event') INP (session p75) Measuring in lab only — misses the slow late interaction
When did the page feel ready? Lighthouse TTI Main-thread quiet heuristic Treating a lab heuristic as a field number
Which island is responsible? web-vitals/attribution interactionTarget node Discarding attribution and losing the trail

The decision rule is simple: hydration questions are answered with marks and longtasks, interaction questions are answered with event timing and web-vitals, and every field number must carry attribution or it cannot be acted on.

Step-by-Step Integration

Follow these steps to stand up end-to-end measurement in an existing islands project. Steps 1–3 run in the browser; steps 4–5 close the loop with lab and field.

Step 1: Register observers in the document head, before island code. Place a single inline script as high in <head> as possible so no early entry is missed. Every observer uses buffered: true.

<script>
  // Earliest possible registration. Stash entries on window so later
  // reporting code can read them regardless of module load order.
  window.__perf = { marks: [], longtasks: [], interactions: [] };
  new PerformanceObserver(l => window.__perf.marks.push(...l.getEntries()))
    .observe({ type: 'mark', buffered: true });
  new PerformanceObserver(l => window.__perf.longtasks.push(...l.getEntries()))
    .observe({ type: 'longtask', buffered: true });
</script>

Step 2: Emit island activation marks using the framework hook from the mechanics section — astro:hydrate, a React useEffect, or a Qwik qidle handler.

Step 3: Capture interactions with the event timing type. The durationThreshold of 40ms filters trivial interactions so you only keep candidates that can affect INP.

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // duration = input delay + processing + presentation, i.e. the INP value
    window.__perf.interactions.push({
      name: entry.name,               // e.g. 'pointerdown', 'click'
      duration: entry.duration,       // the number that becomes INP
      target: entry.target?.closest('[id]')?.id, // nearest island id
    });
  }
}).observe({ type: 'event', durationThreshold: 40, buffered: true });

Step 4: Beacon to RUM on page hide. Use visibilitychange rather than unload so the beacon survives on mobile, and send the highest interaction duration as the page’s INP contribution.

addEventListener('visibilitychange', () => {
  if (document.visibilityState !== 'hidden') return;
  const worst = window.__perf.interactions
    .sort((a, b) => b.duration - a.duration)[0];
  navigator.sendBeacon('/rum', JSON.stringify({
    hydrationDelta: computeDelta(window.__perf.marks),
    inp: worst?.duration ?? 0,
    inpTarget: worst?.target ?? null, // which island owns the worst interaction
  }));
}, { once: true });

Step 5: Reconcile against a lab baseline. Run Lighthouse with --throttling-method=devtools and confirm the lab TBT is within range of the field INP p75. A large gap points to third-party or device variance rather than hydration — the subject of the validation section below.

Measurement & Validation

A measurement is only trustworthy once it has been cross-checked against an independent instrument. Three checks catch the overwhelming majority of instrumentation bugs.

DevTools cross-check

Record a Performance trace, hard-reload, and confirm the flame chart’s hydration long tasks sum to roughly the blockingFromHydration value your observer reported. A large discrepancy means either a missing buffered:true or that layout recalculation is being counted as hydration. The full trace-reading workflow is profiling hydration with the Chrome DevTools Performance panel.

web-vitals cross-check

Run the web-vitals library alongside your hand-rolled observer for one release. The library’s onINP value should match your worst-interaction duration within a few milliseconds. If they diverge, your durationThreshold is filtering an interaction the library counts.

import { onINP } from 'web-vitals/attribution';
onINP((metric) => {
  // Compare against window.__perf worst interaction — they should agree.
  console.log('web-vitals INP', metric.value, 'target', metric.attribution.interactionTarget);
});

Lab vs field reconciliation

The lab TTI and TBT should predict, not equal, the field INP p75. Lab runs on idle hardware; the field captures interactions competing with analytics and service-worker work. A field INP more than ~150ms above lab expectation usually means a third-party script is contending — filter longtask attribution by origin to find it. The step-by-step for the interaction side is how to measure INP in islands architecture apps.

Failure Modes

Marking the scheduling call instead of the commit

Placing performance.mark('end') immediately after a framework’s hydrateRoot or mount call captures scheduling latency, not the reconciliation that actually blocks the thread. The measured delta comes back implausibly small — single-digit milliseconds for a heavy island.

// Anti-pattern: both marks land before reconciliation runs
hydrateRoot(node, <App />);
performance.mark('island:hydrated'); // ← fires before work happens

// Correct: mark inside the lifecycle hook that fires post-commit
useEffect(() => { performance.mark('island:hydrated'); }, []);

Dropping early entries

An observer registered inside a bundled module runs after the first hydration long tasks have already fired. Without buffered: true, those entries never arrive and TBT is under-reported.

// Anti-pattern: no buffering, observer registered late in a module
new PerformanceObserver(cb).observe({ type: 'longtask' }); // misses early tasks

// Correct: buffer, and register in an inline head script
new PerformanceObserver(cb).observe({ type: 'longtask', buffered: true });

Reporting the mean interaction instead of the worst

INP is defined by a high percentile of interactions, not the average. Averaging hides the single slow filter or sort that users actually feel, so the reported number looks healthy while the field metric fails. Always keep and report the worst interaction per page, and aggregate to p75 across sessions in the RUM backend.


← Back to Islands Performance Optimization & Core Web Vitals