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.

The three phases between a tap and the next paint A timeline from tap to paint split into input delay, event processing, and presentation delay, each labelled with its usual cause in a decoupled front end and the corresponding fix. tap next paint input delay event processing presentation thread busy hydrating handler does too much layout and paint cost fix: hydrate less fix: yield to the browser fix: contain layout In a decoupled build, input delay during hydration dominates the first interactions

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.

The worst interaction is the reported one Five interactions in a session, four fast and one slow, with the slow one determining the reported interaction latency. tap menu tap search apply filter reported tap product Optimising the fast interactions changes nothing — find the slow one
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 loadState is not complete, 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.

Which parts of a catalogue route need client JavaScript A page layout mock-up with the filter panel and sort control marked as hydrating islands and the header, breadcrumbs, product grid, and footer marked as static server-rendered regions. header and breadcrumbs — static filter panel island — hydrates sort control — island product card product card product card spec table pagination links — plain anchors, static footer — static Outlined regions ship JavaScript; filled regions ship none

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