Reducing INP on Hydration-Heavy Routes
Catalogues, configurators, and filter-driven listings hydrate hundreds of components on load, and every one of them competes with the visitor’s first tap for the same single main thread.
When to Use This Approach
Reach for this page when:
- Field p75 INP exceeds 200 ms on listing, catalogue, or configurator routes while static article routes are green.
- The gap between desktop and mobile INP is large, which points at CPU-bound work rather than network.
- A framework upgrade or a component-library adoption regressed interaction latency without changing the UI.
The Three Phases of an Interaction
INP measures the worst interaction in a session from input to the next paint, and that interval has three distinct parts. Only one of them is usually the problem, and each has a different remedy.
Implementation Steps
Step 1: Measure the phase breakdown
// rum-inp.js
import { onINP } from 'web-vitals/attribution';
onINP(({ value, attribution: a }) => {
navigator.sendBeacon('/rum/inp', JSON.stringify({
value,
target: a.interactionTarget,
inputDelay: a.inputDelay,
processing: a.processingDuration,
presentation: a.presentationDelay,
loadState: a.loadState,
}));
});
Validation: If loadState is mostly dom-interactive or loading, the slow interactions are happening during hydration and Step 3 is your fix. If it is complete, the handlers themselves are slow and Step 5 applies.
Step 2: Count the hydration surface
# How many client components does this route actually ship?
npx next build 2>/dev/null
jq -r '.pages["/catalog/[slug]"][]' .next/build-manifest.json | wc -l
# And how much code is that, compressed?
jq -r '.pages["/catalog/[slug]"][]' .next/build-manifest.json \
| xargs -I{} cat .next/{} | gzip -c | wc -c
Validation: Compare the component count against the number of things a visitor can actually click on the route. A catalogue with 12 interactive controls hydrating 300 components is shipping 288 components of pure cost.
Step 3: Move non-interactive subtrees to the server
Product cards, breadcrumbs, spec tables, and review lists render once and never respond to input. They do not need to be client components.
// app/catalog/[slug]/page.jsx — server component by default
import { FilterPanel } from './FilterPanel'; // 'use client'
import { SortSelect } from './SortSelect'; // 'use client'
export default async function Catalog({ params }) {
const { products, facets } = await cms.catalog.get(params.slug);
return (
<main>
<FilterPanel facets={facets} />
<SortSelect />
<ul className="grid">
{products.map((p) => (
<li key={p.id}>
<ProductCard product={p} />
</li>
))}
</ul>
</main>
);
}
SEO impact: The server HTML is unchanged — crawlers already saw the full grid — but the client bundle drops to the two controls that need it, which is what the visitor’s phone actually has to execute.
Validation: Rerun Step 2. The component count should fall by an order of magnitude on a typical catalogue.
Step 4: Defer the remaining islands
Even genuinely interactive components rarely need to be ready at load. Hydrate them when they become visible, or on the first interaction with their container.
// components/LazyIsland.jsx
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
export function LazyIsland({ load, fallback, children }) {
const ref = useRef(null);
const [active, setActive] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el || active) return;
const io = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) {
setActive(true);
io.disconnect();
}
}, { rootMargin: '200px' });
io.observe(el);
return () => io.disconnect();
}, [active]);
const Component = active ? lazy(load) : null;
return (
<div ref={ref}>
{active ? <Suspense fallback={fallback}>{<Component />}</Suspense> : children}
</div>
);
}
SEO impact: None negative — the server-rendered markup inside children is what the crawler reads, and it is present regardless of whether hydration ever happens.
Validation: Load the route, tap the primary control immediately, and confirm the interaction completes without waiting for below-the-fold islands.
Step 5: Yield inside expensive handlers
When processing time dominates, the fix is to give the browser a chance to paint before finishing the work.
// utils/yielding.js
const yieldToMain = () =>
'scheduler' in window && 'yield' in scheduler
? scheduler.yield()
: new Promise((resolve) => setTimeout(resolve, 0));
export async function applyFilters(state, products) {
showPendingState(); // paint something immediately
await yieldToMain(); // let the browser render it
const filtered = [];
for (let i = 0; i < products.length; i++) {
filtered.push(match(products[i], state));
if (i % 200 === 0) await yieldToMain();
}
renderResults(filtered);
}
Validation: Record a performance trace of the filter interaction. No single task between input and paint should exceed 50 ms.
Step 6: Contain layout in long lists
/* Each card is its own layout and paint scope, so filtering one row
does not force layout on the whole grid. */
.grid > li {
content-visibility: auto;
contain-intrinsic-size: auto 320px;
}
Validation: Presentation delay in the Step 1 beacons should drop on routes with more than a few dozen cards.
SEO Impact Summary
The metric records the worst interaction in the session, so a single slow control on an otherwise fast page determines the score.
| Signal | What improves | What breaks if misconfigured |
|---|---|---|
| Page experience | Interaction latency moves into the good bucket on the routes that carry commercial intent | Deferring hydration of a control the visitor taps immediately makes it worse |
| Crawl parity | Server-rendered markup is unchanged, so the crawler view is unaffected | Moving content into a client-only island removes it from server HTML entirely |
| Conversion | Filters and configurators respond within a frame instead of after a stall | Yielding without a pending state makes the UI feel unresponsive rather than fast |
| Bundle economics | Less JavaScript parsed on every visit, on every route that reuses the component | Over-splitting turns execution time into request waterfalls |
Measurable signals to watch:
- Field p75 INP for the catalogue URL group.
- The share of slow interactions where
loadStateis notcomplete, which should approach zero after Step 3. - Compressed client bundle bytes per route, which is the leading indicator for all of the above.
What Actually Needs to Hydrate
The single highest-leverage change is deciding, per component, whether it responds to input at all. Most of a catalogue route does not.
Edge Cases and Gotchas
Analytics and tag managers are the hidden input delay A third-party tag that executes during hydration competes for exactly the same thread. Load them with the lowest priority available, and measure INP with and without them before concluding that your own code is the problem.
Deferring the wrong island is worse than not deferring If the first thing a visitor does on a catalogue is open the filter panel, deferring that panel until it scrolls into view converts a fast interaction into a slow one. Defer by likelihood of use, not by position on the page.
Server components remove interactivity, not just cost Converting a component to render on the server means it can no longer hold state. Anything with a hover menu, a tooltip, or a local toggle needs to stay a client component or have that behaviour rebuilt in CSS.
Pagination links must remain real anchors It is tempting to make pagination a client-side control to avoid a navigation. Doing so removes the crawlable links that make deep pages discoverable — see crawlable pagination without rel next prev for why the anchors matter more than the interaction.
Frequently Asked Questions
Why is INP fine on desktop and poor on mobile? Hydration cost scales with CPU speed, not bandwidth. A bundle that executes in 200 ms on a laptop can take well over a second on a mid-tier phone, and any tap landing inside that window queues behind it. Faster connections do not help; less JavaScript does.
Does code-splitting always improve INP? No. Splitting reduces up-front work, but if the chunk is fetched at click time you have converted execution into network latency and the visitor still waits. Split along interaction boundaries and prefetch on hover or visibility.
Is INP part of how Google ranks pages? It feeds the page experience signals, which act as a tie-breaker among comparably relevant results rather than as a primary factor. It has no effect on crawling or indexing — a page with poor interaction latency is indexed exactly like a fast one.
How do I stop this regressing after the fix? Add a bundle-size assertion per route to the same CI job that asserts the other vitals. Interaction latency regresses through accumulation — one convenient import at a time — and a byte budget is the only check that catches it before merge.
Part of: Core Web Vitals in Headless Front Ends
Related
- Fixing LCP on Headless Product Pages — the paint-speed metric on the neighbouring template
- Framework-Specific Rendering Tradeoffs — how the rendering mode you pick decides how much hydration there is to reduce
- Diagnosing Render-Blocking JavaScript in Headless — find the scripts occupying the thread before your own code runs