How to Measure INP in Islands Architecture Apps

Interaction to Next Paint is the Core Web Vital that islands apps are most likely to fail quietly. A page can hydrate fast and post a healthy Total Blocking Time, yet still record a 400ms INP because one island’s click handler runs a synchronous long task the moment a user filters a table. This guide is the interaction-side procedure of measuring hydration and interactivity metrics: how to capture INP precisely, attribute it to the specific island responsible, and confirm the number reproduces for real users rather than only in the lab.

Prerequisites


The Anatomy of an INP Measurement

INP is not a single timestamp — it is the sum of three sub-phases of one interaction. Seeing them separately is what lets you tell an input-delay problem (main thread busy) from a processing problem (slow handler) from a presentation problem (heavy re-render).

INP Sub-Phases for an Island Interaction A horizontal timeline from user tap to next paint, split into three bands: input delay in grey, processing time in blue where the island handler runs, and presentation delay in grey. Below, a brace labels the whole span as INP. tap next paint input delay processing — island handler runs this is where a long task is fixable presentation INP = entry.duration for this interaction group web-vitals groups pointerdown + pointerup + click by interactionId

Diagnostic Steps

Step 1 — Observe event timing entries

Goal: Capture every interaction with enough duration to matter, tagged with the island it hit.

Register the observer as early as possible with buffered: true. The durationThreshold: 40 keeps trivial interactions out while staying below the 200ms INP target so nothing borderline is missed.

// Place in an inline <head> script, before island bundles execute.
window.__inp = [];
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // entry.duration is the full input-to-paint latency for this event.
    // interactionId groups the pointer/click events of one logical tap.
    if (!entry.interactionId) continue; // skip non-interaction events
    window.__inp.push({
      id: entry.interactionId,
      name: entry.name,                       // 'pointerdown' | 'click' | 'keydown'
      duration: entry.duration,               // the candidate INP value
      island: entry.target?.closest('[id]')?.id ?? 'unknown', // attribution
    });
  }
}).observe({ type: 'event', durationThreshold: 40, buffered: true });

Expected output: After clicking around, window.__inp holds entries such as { id: 1423, name: 'click', duration: 312, island: 'filter-island' }. The island field is the attribution that turns a bare number into an actionable target.


Step 2 — Reduce interaction groups to a single INP candidate

Goal: Collapse the grouped events into one value per interaction, taking the longest, exactly as the metric defines it.

// INP uses the worst event within an interactionId group.
function worstPerInteraction(entries) {
  const byId = new Map();
  for (const e of entries) {
    const prev = byId.get(e.id);
    if (!prev || e.duration > prev.duration) byId.set(e.id, e);
  }
  // The page's INP contribution is the worst group overall (p75 across sessions
  // is computed server-side; per page you report the worst single interaction).
  return [...byId.values()].sort((a, b) => b.duration - a.duration)[0];
}
console.log(worstPerInteraction(window.__inp));

Expected output: A single object — the slowest interaction on the page — e.g. { id: 1423, duration: 312, island: 'filter-island' }. This is the value that, aggregated to p75 across sessions, becomes your reported INP.


Step 3 — Confirm with the web-vitals library

Goal: Cross-check your hand-rolled value against the reference implementation and read its attribution.

import { onINP } from 'web-vitals/attribution';

onINP((metric) => {
  // metric.value should match the worst-group duration from Step 2.
  // attribution.interactionTarget is a selector for the responsible node.
  navigator.sendBeacon('/rum', JSON.stringify({
    inp: metric.value,
    target: metric.attribution.interactionTarget,   // e.g. '#filter-island button'
    phase: metric.attribution.longestScriptEntry?.name ?? 'unknown',
  }));
}, { reportAllChanges: true }); // reportAllChanges surfaces every candidate in dev

Expected output: A beacon payload like { "inp": 312, "target": "#filter-island button", "phase": "..." }. If metric.value disagrees with Step 2 by more than a few milliseconds, your durationThreshold filtered an interaction the library counted — lower it and re-test.


Step 4 — Reproduce the breakdown in DevTools

Goal: See the input-delay / processing / presentation split so you know which phase to fix.

  1. Open the Performance panel, enable CPU throttling (4×), and start recording.
  2. Perform the slow interaction identified in Step 1 (e.g. the filter click on filter-island).
  3. Stop recording and open the Interactions track. The interaction appears as a bar whose length is the INP.
  4. Hover the bar: DevTools shows the input delay before the handler, the script processing duration, and the presentation delay after.
  5. If the processing band dominates, expand the Main track beneath it to find the long task — that script is the island handler to optimise.

Expected output: An Interactions-track entry whose processing segment maps to your island’s event handler in the Main track, confirming the handler — not input delay — owns the latency.


Verification

Confirm the measurement is real and reproducible before acting on it.

  1. Group-count sanity. The number of interactionId groups should equal the number of discrete interactions you performed. If it is higher, you are counting continuous events — filter to click, keydown, and tap-derived pointerup.
  2. Lab-to-field agreement. The worst interaction from Step 2 should reproduce in the RUM p75 within ~150ms. A large gap means the slow path is a real-user interaction your manual test never triggered — replay the exact interactionTarget from the field beacon.
  3. Phase attribution. Re-run Step 4 and confirm the dominant phase is stable across three recordings. If input delay dominates on one run and processing on another, the main thread is contended by unrelated work — profile the hydration window per profiling hydration with the Chrome DevTools Performance panel.

Troubleshooting

`entry.interactionId` is always 0, so nothing is captured

Root cause: An interactionId of 0 means the event was not part of a user interaction — it is a programmatically dispatched or continuous event (scroll, mousemove). Only discrete user interactions receive a non-zero id.

Fix: Keep the if (!entry.interactionId) continue; guard from Step 1 so non-interaction events are skipped, and verify you are genuinely clicking or typing rather than triggering synthetic events in a test harness. In automated tests, drive real input via the DevTools Protocol so the browser assigns interaction ids.

DevTools INP overlay disagrees with the `web-vitals` value

Root cause: The DevTools live INP overlay reports the current worst interaction of the open session, which keeps updating as you interact. The web-vitals onINP callback only reports at page hide (or on every change with reportAllChanges), so mid-session the two naturally differ.

Fix: Compare them at the same moment — trigger visibilitychange by switching tabs, which fires the final onINP, then read the DevTools overlay. They should converge. During development, pass reportAllChanges: true so the callback tracks the overlay in real time.

INP spikes only after the island has been idle for a while

Root cause: A lazily-hydrated island whose bundle was evicted, or whose handler triggers a fresh dynamic import on first interaction, pays the import and parse cost inside the interaction window — inflating processing time. This is common with client:idle or interaction-triggered activation.

Fix: Pre-warm the handler’s module during idle time with a low-priority import() so the interaction only runs already-parsed code, and break any synchronous work in the handler into yielded chunks with scheduler.yield(). Reducing the imported code is covered in reducing JavaScript payload in islands.


← Back to Measuring Hydration & Interactivity Metrics