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.
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.markboundaries around island activation to compute a hydration delta. - Attributing Total Blocking Time to hydration with
longtasktiming entries. - Capturing INP and FID from
eventandfirst-inputtiming entries, and from theweb-vitalslibrary. - Shipping all of it to a RUM pipeline with attribution that names the responsible island.
What falls outside its scope:
- Reducing the numbers once measured — that is reducing JavaScript payload in islands and activation scheduling.
- Comparing frameworks head to head, which belongs to framework performance benchmarks.
- The mechanics of selective activation itself, covered in understanding partial hydration.
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.
Related
- How to Measure INP in Islands Architecture Apps — the step-by-step diagnostic for capturing interaction latency with
eventtiming and theweb-vitalslibrary. - Profiling Hydration with the Chrome DevTools Performance Panel — how to record a trace and read hydration long tasks in the flame chart.
- Reducing JavaScript Payload in Islands — once you have measured the cost, the techniques that reduce it at the source.
- Framework Performance Benchmarks — apply these instruments across frameworks to compare TTI and TBT fairly.
- Understanding Partial Hydration — the activation mechanism whose cost these metrics quantify.
← Back to Islands Performance Optimization & Core Web Vitals