Measuring INP on Static Sites with Real-User Monitoring

Static sites are fast by default on LCP and CLS, but Interaction to Next Paint (INP) is the one Core Web Vital you cannot measure in a lab — it needs a real person to interact. The only way to know your true INP is to collect it in the field with real-user monitoring (RUM): the web-vitals library reports the metric, attributes it to a specific interaction, and beacons it to a dashboard. This recipe covers wiring up that pipeline, attributing slow interactions, and reconciling lab versus field numbers. It belongs to JavaScript Hydration & Partial Rendering — where the cause of bad INP usually lives — inside the broader Performance Optimization & Core Web Vitals for SSGs effort.

Prerequisites

  • A deployed static site (Astro, Eleventy, Hugo, or Jekyll) with some interactivity — search, menus, tabs, or hydrated components.
  • An endpoint to receive beacons: a serverless function, an analytics provider that ingests Web Vitals, or a logging endpoint that writes to a store you can query.
  • Lighthouse or lhci for the lab comparison, plus the field data once it accumulates.

Why the Lab Cannot Tell You INP

Lighthouse runs a synthetic page load and reports Total Blocking Time (TBT) as a lab proxy because there is no sustained user interaction to measure. INP measures the latency from a real click, tap, or keypress to the next frame painted, across the whole page visit, reporting roughly the worst interaction. You can have a perfect TBT and still ship a 600 ms INP if a single hydrated handler blocks the main thread when a user actually clicks it. That gap is exactly why field measurement is mandatory.

Real-user monitoring data flow for INP A user interaction in the browser is measured by the web-vitals library, which sends a beacon to a collector endpoint that aggregates the 75th percentile INP onto a dashboard, with attribution to the slowest interaction target. Client interaction to field dashboard Browser user clicks web-vitals onINP Beacon sendBeacon value + target Collector serverless fn store Dashboard p75 INP
A real interaction is measured client-side by web-vitals, beaconed with its attribution to a collector, and aggregated to a 75th-percentile INP on a dashboard.

Step 1: Collect INP with the Attribution Build

Install the web-vitals library and import its attribution build, which adds the interaction target and a phase breakdown to each report:

npm i web-vitals
// rum.js — loaded with defer or as a module
import { onINP } from 'web-vitals/attribution';

function sendToCollector(metric) {
  const body = JSON.stringify({
    name: metric.name,                 // "INP"
    value: metric.value,               // milliseconds
    rating: metric.rating,             // good | needs-improvement | poor
    path: location.pathname,
    // attribution: which interaction was slowest, and why
    target: metric.attribution.interactionTarget,   // CSS selector
    type: metric.attribution.interactionType,       // pointer | keyboard
    inputDelay: metric.attribution.inputDelay,
    processing: metric.attribution.processingDuration,
    presentation: metric.attribution.presentationDelay,
  });
  navigator.sendBeacon('/api/vitals', body);
}

onINP(sendToCollector, { reportAllChanges: false });

onINP fires when the page is backgrounded or unloaded, reporting the worst interaction of the visit. navigator.sendBeacon queues the request without blocking unload. The whole module is around 2 to 3 KB compressed — small enough that it does not itself harm the interactivity you are measuring.

Step 2: Receive and Aggregate

A minimal collector endpoint writes each beacon to a store you can query for percentiles. On Netlify or Cloudflare, a function works:

// /api/vitals — serverless function
export default async (request) => {
  const m = await request.json();
  if (m.name !== 'INP') return new Response(null, { status: 204 });
  await store.append('inp', {
    ts: Date.now(), path: m.path, value: m.value,
    target: m.target, type: m.type,
    inputDelay: m.inputDelay, processing: m.processing, presentation: m.presentation,
  });
  return new Response(null, { status: 204 });
};

Then aggregate to the 75th percentile per path — that is the threshold Google rates. Track p75, not the average, because the average hides the slow tail that actually hurts users. Many teams skip the self-built collector and point the same beacon at an analytics product that ingests Web Vitals; the client code is identical.

Step 3: Attribute and Reconcile Lab vs Field

The attribution fields turn a number into an action. Group your field INP by target to find the offending element, then read the phase breakdown:

  • High inputDelay: the main thread was busy when the user interacted — usually hydration or a long task. This is the hydration-discipline fix from Astro Islands vs Full Hydration Performance.
  • High processingDuration: your event handler itself is slow — break up the work, debounce, or move it off the main thread.
  • High presentationDelay: rendering the result is expensive — reduce DOM churn or large style recalcs.

Then reconcile with the lab. Run Lighthouse for the TBT proxy and compare it against the field p75 INP:

npx lhci autorun --collect.url=https://your-site.example.com \
  --assert.preset=lighthouse:recommended

Measured Impact

On a documentation site with a hydrated search box and a client-rendered filter, RUM revealed an INP problem the lab never showed. After attribution pointed at the search input's keydown handler (high inputDelay from eager hydration), switching that island to hydrate on first interaction and debouncing the handler moved the numbers. Field values are p75 over ~4,000 page visits; lab is a Lighthouse mobile run:

StageLab TBT (Lighthouse)Field INP p75 (RUM)Slowest target (attribution)
Before (eager hydration)120 ms480 msinput.search keydown
After (hydrate on interaction + debounce)90 ms180 msbutton.filter pointer

The lab TBT barely moved (120 → 90 ms) and would never have flagged the problem — but field INP dropped from a poor 480 ms to a good 180 ms, crossing the 200 ms threshold. This is the entire argument for RUM: the lab said "fine," the field said "poor," and only the field number reflected what users felt. Pair this measurement with the hydration work in the parent guide so the fixes are durable.

Pitfalls & Rollback

  • Tracking the average instead of p75: the average masks the slow interactions. Always aggregate at the 75th percentile, the rating threshold.
  • Using the base build instead of web-vitals/attribution: without attribution you get a number but no target or phase breakdown, so you cannot tell what to fix.
  • Beaconing on every change: set reportAllChanges: false and report once on unload; flooding the collector adds cost and noise without better data.
  • Thin data: INP needs real traffic to stabilize. A handful of visits gives a noisy p75. Let data accumulate over days before acting, and segment by path so a slow page is not hidden in a site-wide aggregate.
  • Treating lab and field as interchangeable: they answer different questions. Keep both — lab for pre-deploy regression gates, field for the truth about real interactions.
  • Rollback: the RUM client is one small deferred module and a beacon endpoint. Removing it is deleting the import and the route; it carries no caching or build state. Because it is deferred and uses sendBeacon, it does not affect the metrics it measures even while live.

Conclusion

INP is the Core Web Vital the lab cannot give you, so measure it in the field: the web-vitals attribution build reports the metric and the slowest interaction, a small collector aggregates the 75th percentile per path, and the attribution breakdown tells you whether to fix hydration, the handler, or rendering. Reconcile that field p75 against Lighthouse's TBT proxy and trust the field number when they disagree. Once you can see which interaction is slow, the fixes live in hydration discipline — keep both lab gates and field monitoring running.

FAQ

Why can't Lighthouse measure INP?

INP is an interaction metric — it needs a real user to click, tap, or type. Lighthouse runs a synthetic load with no sustained interaction, so it reports Total Blocking Time as a proxy, not INP. The only way to get a true INP value is from real users in the field, which is what real-user monitoring collects.

How does the web-vitals library attribute INP to an interaction?

The attribution build of the web-vitals library reports the interaction target element selector, the event type, and a breakdown of input delay, processing time, and presentation delay for the slowest interaction. That tells you which element and which phase to fix rather than just a number.

How much JavaScript does RUM add to the page?

The web-vitals attribution build is small, roughly 2 to 3 KB compressed, and it sends one beacon per page using navigator.sendBeacon, which does not block unload. The measurement cost is negligible compared to the interaction issues it helps you find.

Why is my field INP worse than my lab score suggested?

Lab tests run on a fixed device and network with no third-party variability. Real users hit slower devices, contended main threads, and late-loading third-party scripts that delay event handlers. Field INP captures the slow tail of real interactions that a single lab run never exercises.

What INP value should I target?

Google's good threshold is an INP at or below 200 milliseconds at the 75th percentile of page loads. Above 500 milliseconds is rated poor. Track the 75th percentile from your RUM data, not the average, because the average hides the slow interactions that hurt real users.

Static Site Generators in Production