Measuring INP Impact of Lazy Hydration
The dashboard scores well on every load metric and badly on Interaction to Next Paint. At the 75th percentile it reports 420 ms, past the 200 ms threshold, and the reported interaction is a filter button whose handler does almost nothing:
INP 420 ms
interactionTarget: button.filter-toggle
inputDelay: 40 ms
processingDuration: 350 ms
presentationDelay: 30 ms
Three hundred and fifty milliseconds of processing for a handler that sets one boolean. Profiling the handler in isolation shows it taking well under a millisecond. The time is not in the handler β it is in the chunk the handler triggered, and in the mounting of the component that chunk contains.
This is the interaction-side counterpart to the load-time attribution in attributing LCP regressions to a specific chunk, and it is the metric most affected by aggressive component splitting.
Root cause: the first interaction pays for the whole region
INP measures from input to the next paint that reflects it. When a lazily-hydrated region is touched for the first time, everything that region needs happens inside that window: the chunk request, its evaluation, the componentβs mount, and the render. Deferring work does not remove it β it relocates it onto whichever interaction arrives first.
That relocation is often the right trade. The techniques in component-level code splitting beyond routes exist precisely to keep unneeded work off the critical path, and for a region most sessions never touch, moving its cost onto the few sessions that do is a clear win. What makes it a problem is doing it to a region that nearly every session touches immediately.
Instrumenting the split
The attribution build of the vitals library reports the three components of INP. Adding two flags β whether a chunk was in flight, and whether this was the regionβs first interaction β turns the metric into a diagnosis.
// inp-attribution.js β report INP with hydration context
import { onINP } from 'web-vitals/attribution';
const hydrated = new Set(); // region ids that have already mounted
export function markHydrated(regionId) { hydrated.add(regionId); }
onINP(({ value, attribution }) => {
const entry = attribution.processedEventEntries[0];
const start = entry ? entry.startTime : 0;
const end = start + value;
const inflight = performance.getEntriesByType('resource')
.filter((e) => e.initiatorType === 'script' &&
e.startTime < end && e.startTime + e.duration > start)
.map((e) => e.name.slice(e.name.lastIndexOf('/') + 1));
const region = attribution.interactionTarget
? document.querySelector(attribution.interactionTarget)?.closest('[data-region]')?.dataset.region
: null;
report('inp', {
value: Math.round(value),
target: attribution.interactionTarget,
inputDelay: Math.round(attribution.inputDelay),
processing: Math.round(attribution.processingDuration),
presentation: Math.round(attribution.presentationDelay),
// The two fields that make this actionable:
chunksInFlight: inflight,
firstInteraction: region ? !hydrated.has(region) : null,
});
});Charted with firstInteraction as a dimension, the metric separates into two populations that should never have been averaged: a first interaction in the hundreds of milliseconds, and every subsequent one in the tens.
Reading the three components
Each component points somewhere different.
Input delay is time the main thread was busy before your handler could run. A large input delay during page load means hydration of another region is blocking input β the region being interacted with is a victim, not the cause. Splitting hydration into smaller chunks of work is the fix, as covered in streaming SSR and selective hydration with code splitting.
Processing time is your handler plus anything it awaits before the render completes. A chunk fetch triggered by the handler lands here, and it is the dominant term in most first-interaction cases.
Presentation delay is layout, paint, and compositing after the render. A large value here usually means the newly-mounted component forced an expensive layout, not that the chunk was slow.
Fixing it: prefetch, or do not defer at all
Two remedies, chosen by how often the region is touched.
For a region most sessions use, start the fetch before the interaction. The intent-based preloading described in lazy loading modal and dialog components removes the fetch from the interaction window entirely, leaving only mount and render.
For a region nearly every session uses immediately, reconsider the split. A filter panel on a dashboard is not a good deferral candidate: the interaction probability approaches 100%, so the deferral guarantees a slow first interaction in exchange for a saving almost nobody realises.
// hydrate-on-idle.js β pre-hydrate high-probability regions without blocking load
const idle = window.requestIdleCallback || ((fn) => setTimeout(fn, 200));
export function prehydrate(loaders) {
idle(() => {
// Sequential, not parallel: idle time is for filling gaps, not saturating
// the network while the user may still be interacting with the page.
loaders.reduce((chain, load) => chain.then(load).catch(() => {}), Promise.resolve());
}, { timeout: 3000 });
}Step-by-step verification
-
Confirm the split populations. Chart INP grouped by
firstInteraction. If the two groups are not visibly different, hydration is not your problem. -
Confirm the chunk is in the window. The slow interactions should list a chunk in
chunksInFlight. If none is listed, the cost is handler work, not loading. -
Check which component dominates. A large input delay redirects the investigation to whatever was already running; a large processing time confirms the interaction itself started the work.
-
Apply prefetching and re-measure. After adding intent-based prefetching, first-interaction INP should fall to roughly the mount-plus-render cost, with the fetch removed from the window.
-
Re-check the load metrics. Prefetching moves bytes earlier, so confirm it has not regressed the load-time metrics you were protecting in the first place.
-
Segment by device class. Evaluation and mount cost scale with CPU, so a change that looks sufficient on desktop hardware can leave mid-tier mobile well past the threshold.
Edge cases and gotchas
Interactions during page load. An interaction that lands while the main thread is still hydrating shows a large input delay regardless of the region touched. These sessions are measuring hydration scheduling, not the widget.
Regions with no stable identity. The firstInteraction flag needs a durable region id. A component that remounts on every parent render resets the flag and makes every interaction look like the first.
Prefetch that never completes. A prefetch started on hover but cancelled by navigation leaves no benefit and consumes bandwidth. Track prefetch hit rate alongside INP, or the fix silently stops working.
Aggregate metrics hiding the tail. If only 4% of sessions touch a deferred region, its terrible first-interaction INP may barely move the page-level number while being a genuinely bad experience for those users. Track it per region, not only per page.
FAQ
Why is only the first click on a widget slow?
Because the first click pays for everything the region needs and later clicks pay for nothing. The chunk has to be fetched, parsed, and evaluated, the component has to mount, and event handlers have to attach β all inside the interaction window the metric measures. Once that has happened, the same click runs against already-resident code and completes in a few milliseconds. A metric that averages both is reporting an experience nobody has.
Does lazy hydration make INP worse or better?
Both, on different interactions. Deferring hydration removes main-thread work from page load, which improves the responsiveness of every interaction that happens before the deferred region is touched. It moves that work onto the first interaction with the deferred region, which makes that specific interaction worse. Whether the trade is good depends on how many sessions touch the region at all β the same interaction-probability judgement that governs the split itself.
Which part of INP does a chunk fetch land in?
Usually processing time, because the handler that triggers the import stays on the stack awaiting it, and the next paint cannot happen until the resulting render completes. If the main thread was already busy when the input arrived β during hydration of another region, for example β the cost appears in input delay instead. A large input delay points at work that was already running; a large processing time points at work the interaction itself started.
Related
- Measuring Real-User Chunk Loading Performance β the parent guide on field instrumentation
- Attributing LCP Regressions to a Specific Chunk β the load-time counterpart of this attribution
- Lazy Loading Modal and Dialog Components β intent-based prefetching, the main remedy here
- Streaming SSR and Selective Hydration With Code Splitting β when input delay, not processing, is the dominant term