Skip to content

6.2 — INP playbook

Module 6 · Lesson 2 · 🔴 Advanced · ~45 min

INP is the hardest vital to fix and the one most commerce sites fail. It's also where React apps lose to simpler stacks. This is the diagnostic reference.


Step 1 — Find out which interactions are bad

INP is a whole‑page metric, but it's caused by specific interactions. You need the field data with attribution — lab tools cannot tell you which button your users tap.

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

onINP(({ value, attribution }) => {
  reportMetric({
    name: 'INP',
    value,
    attribution: {
      target: attribution.interactionTarget,       // selector — the key field
      type: attribution.interactionType,           // 'pointer' | 'keyboard'
      inputDelay: attribution.inputDelay,
      processingDuration: attribution.processingDuration,
      presentationDelay: attribution.presentationDelay,
      loadState: attribution.loadState,            // was the page still loading?
      nextPaintTime: attribution.nextPaintTime,
      // Scripts responsible, with source locations
      longAnimationFrames: attribution.longAnimationFrameEntries?.map((f: any) => ({
        duration: f.duration,
        blockingDuration: f.blockingDuration,
        scripts: f.scripts?.map((s: any) => ({
          url: s.sourceURL, fn: s.sourceFunctionName, dur: s.duration, invoker: s.invoker,
        })),
      })),
    },
  }, { attribution: true });
});

Then build the leaderboard:

SELECT
  page_type,
  JSON_VALUE(attribution, '$.target')  AS target,
  COUNT(*)                             AS occurrences,
  APPROX_QUANTILES(value, 100)[OFFSET(75)] AS p75,
  AVG(CAST(JSON_VALUE(attribution, '$.inputDelay') AS FLOAT64))          AS avg_input_delay,
  AVG(CAST(JSON_VALUE(attribution, '$.processingDuration') AS FLOAT64))  AS avg_processing,
  AVG(CAST(JSON_VALUE(attribution, '$.presentationDelay') AS FLOAT64))   AS avg_presentation
FROM vitals
WHERE name = 'INP' AND device_class = 'mobile'
  AND ts >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
GROUP BY 1, 2
ORDER BY occurrences * p75 DESC
LIMIT 20;

Aurora's leaderboard, week 1:

Page Target Count p75 Input delay Processing Presentation
PLP input.facet-checkbox 412K 640 ms 40 ms 520 ms 80 ms
PDP button.size-swatch 380K 410 ms 210 ms 140 ms 60 ms
PLP a.product-tile 290K 380 ms 180 ms 150 ms 50 ms
Search input#search 210K 520 ms 30 ms 460 ms 30 ms
Header button.cart-toggle 190K 290 ms 120 ms 130 ms 40 ms

Sort by count × p75, not by p75 alone. A 2‑second interaction that happens 40 times a week doesn't matter; a 400 ms one that happens 400K times is your entire INP score.


Step 2 — Read the sub‑parts

The sub‑part that dominates tells you which section below to read.

Input delay      → main thread was busy      → Section A
Processing       → your handler + React      → Section B
Presentation     → style/layout/paint        → Section C

From the table above: - facet-checkbox and #search are processing problems → React render cost. - size-swatch and product-tile are input delay problems → something else is hogging the main thread when the user taps.

Those need completely different fixes, which is why the breakdown matters.


Section A — High input delay

The main thread was busy when the user tapped. Your handler hadn't even started.

A1 — Interactions during page load

Check attribution.loadState. If it's dom-interactive or loading, users are tapping before hydration finishes.

SELECT JSON_VALUE(attribution, '$.loadState') AS load_state,
       COUNT(*), APPROX_QUANTILES(value, 100)[OFFSET(75)] AS p75
FROM vitals WHERE name = 'INP' GROUP BY 1;

If a large share is during load, your INP problem is a hydration problem: → 4.4 Hydration cost, 3.2 Boundaries

Mitigations that help immediately: - Reduce Client Components (less to hydrate) - Suspense boundaries for selective hydration (the tapped region hydrates first) - Server Actions via <form action> so early taps work without hydration (5.6)

A2 — Third‑party scripts

longAnimationFrames[].scripts[].sourceURL names the culprit directly. Aggregate by hostname:

SELECT JSON_VALUE(script, '$.url') AS host, COUNT(*), SUM(CAST(JSON_VALUE(script, '$.dur') AS FLOAT64))
FROM vitals, UNNEST(JSON_QUERY_ARRAY(attribution, '$.longAnimationFrames[0].scripts')) AS script
WHERE name = 'INP' GROUP BY 1 ORDER BY 3 DESC;

2.3 Third‑party scripts

A3 — Timers and intervals

A setInterval doing work every 100 ms is a guaranteed input‑delay generator.

rg -n 'setInterval|setTimeout\([^,]+,\s*(\d{1,3})\)' app components lib

Common offenders on commerce sites: countdown timers ("Sale ends in 02:14:33"), carousel auto‑advance, live‑stock pollers, and "N people are viewing this" widgets.

// ❌ setState every second → a React render every second, forever
useEffect(() => {
  const id = setInterval(() => setTimeLeft(computeTimeLeft(endsAt)), 1000);
  return () => clearInterval(id);
}, [endsAt]);

// ✅ Update the DOM directly, outside React, and stop when hidden
useEffect(() => {
  const el = ref.current;
  if (!el) return;
  let raf = 0;
  let last = '';
  const tick = () => {
    if (document.visibilityState === 'visible') {
      const next = formatTimeLeft(endsAt);
      if (next !== last) { el.textContent = next; last = next; }
    }
    raf = window.setTimeout(tick, 1000) as unknown as number;
  };
  tick();
  return () => clearTimeout(raf);
}, [endsAt]);

A4 — Long tasks from your own code

Analytics payload building, price recalculation, facet counting. Chunk them with scheduler.yield() (5.5).


Section B — High processing time

Your handler and React's render took too long.

B1 — Too many components re‑render

The single most common commerce cause. A filter toggle re‑rendering 48 product cards.

Diagnose: React Profiler → record the interaction → count components in the commit.

Fixes, in order: 1. startTransition — the biggest perceived win, cheapest change (5.5) 2. State architecture — split contexts, move filters to the URL (5.3) 3. Memoization — with genuinely stable props (5.2) 4. Virtualization — only if you actually have 500+ items (5.4)

B2 — Expensive work in the handler

// ❌ Synchronous analytics + formatting in a click handler
const onSelect = (variantId: string) => {
  const payload = buildFullAnalyticsPayload(product, variantId);   // 80ms
  window.dataLayer.push(payload);                                   // synchronous, 40ms
  setSelected(variantId);
};

// ✅ Update state first, defer the rest past the paint
const onSelect = (variantId: string) => {
  setSelected(variantId);                     // urgent
  requestIdleCallback(() => {                 // after paint
    window.dataLayer.push(buildFullAnalyticsPayload(product, variantId));
  }, { timeout: 2000 });
};

Rule: nothing in an event handler except the state update the user is waiting for. Analytics, logging, prefetching, and side effects go after the paint.

B3 — Cascading state updates

Multiple commits per interaction. Usually derived state in an effect (5.1).

B4 — Expensive computation per item

// ❌ 48 × (Intl construction + badge computation) on every render
function ProductCard({ product, locale }) {
  const price = new Intl.NumberFormat(locale, { style: 'currency', currency: product.currency })
    .format(product.price / 100);        // ~1ms each
  // …
}

// ✅ Compute on the server; the client receives strings
// (or cache the formatter — see 4.3)

Section C — High presentation delay

React finished, but the browser couldn't paint quickly.

C1 — Huge DOM

Style recalculation and layout scale with node count.

document.querySelectorAll('*').length      // > 3,000 is a problem on mobile

→ Virtualization, content-visibility, fewer nodes per component (5.4)

C2 — Forced synchronous layout

In the Performance trace, a purple "Layout" bar inside a scripting task.

// ❌ Read → write → read forces layout each iteration
elements.forEach((el) => {
  const h = el.offsetHeight;
  el.style.height = `${h + 10}px`;
});

// ✅ Batch reads then writes
const heights = elements.map((el) => el.offsetHeight);
elements.forEach((el, i) => { el.style.height = `${heights[i] + 10}px`; });

Common triggers: offsetTop, offsetHeight, getBoundingClientRect(), scrollTop, getComputedStyle(), and window.getSelection().

C3 — Non‑composited animation

Animating width, height, top, left, or margin forces layout every frame. → transform/opacity only (2.5)

C4 — Expensive CSS

Deep descendant selectors, :has() over large subtrees, or a huge stylesheet. If "Recalculate Style" is a visible bar in your trace, investigate; otherwise this is rarely the problem.


Interaction‑specific playbooks

Filter toggle (PLP)

Target INP: < 150ms
1. startTransition around the results update; checkbox state outside it 2. Filters in the URL, filtering on the server 3. Memoized product cards with stable props 4. content-visibility on off‑screen tiles 5. Facet counts computed server‑side, not client‑side

Search typeahead

Target INP: < 100ms (the input must feel native)
1. Uncontrolled input, or useDeferredValue for the results 2. Debounce the network call (150–250 ms), defer the render 3. Cap suggestions at 8; don't render 50 4. AbortController on superseded requests 5. Never re‑render the input on results arrival

'use client';
export function SearchBox() {
  const [query, setQuery] = useState('');
  const deferred = useDeferredValue(query);
  const abortRef = useRef<AbortController>();

  const { data: suggestions } = useSWR(
    deferred.length >= 2 ? `/api/suggest?q=${encodeURIComponent(deferred)}` : null,
    async (url) => {
      abortRef.current?.abort();
      abortRef.current = new AbortController();
      const res = await fetch(url, { signal: abortRef.current.signal });
      return res.json();
    },
    { keepPreviousData: true, dedupingInterval: 300 },
  );

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <SuggestionList items={suggestions?.slice(0, 8) ?? []} stale={query !== deferred} />
    </>
  );
}

Variant selection (PDP)

Target INP: < 150ms
1. Swatch highlight is urgent; everything else is a transition 2. Preload variant images on hover 3. Price/stock update via a transition — never block the swatch on a network call 4. Don't remount the gallery (no key change)

Add to cart

Target INP: < 200ms
1. Optimistic cart badge (5.6) 2. <form action> so it works pre‑hydration 3. Analytics after paint 4. Don't open the mini‑cart drawer synchronously with the mutation

Product tile tap (navigation)

Target INP: < 200ms
1. loading.tsx so something paints immediately 2. Prefetch on hover/touch‑start 3. startTransition around router.push 4. Reduce hydration cost — this interaction's delay is usually input delay from load


Lab reproduction

INP can't be measured properly in the lab, but you can reproduce a specific interaction:

// tests/inp-interaction.spec.ts
import { test, expect } from '@playwright/test';

test('filter toggle stays under 200ms', async ({ page }) => {
  const client = await page.context().newCDPSession(page);
  await client.send('Emulation.setCPUThrottlingRate', { rate: 4 });

  await page.goto('/c/womens-knitwear');
  await page.waitForLoadState('networkidle');

  const duration = await page.evaluate(async () => {
    const checkbox = document.querySelector<HTMLInputElement>('input.facet-checkbox')!;
    const start = performance.now();
    checkbox.click();
    // Wait for the next painted frame — this is what INP measures
    await new Promise<void>((r) =>
      requestAnimationFrame(() => requestAnimationFrame(() => r())),
    );
    return performance.now() - start;
  });

  expect(duration).toBeLessThan(200);
});

Run these for your top 5 interactions in CI. They're not INP, but they catch regressions in the thing INP measures.


Aurora's INP journey

Change PLP p75 INP
Baseline 640 ms
startTransition on filters 380 ms
Split cart context 290 ms
Filters moved to URL 205 ms
Product cards memoized, formatters cached 168 ms
Third‑party deferral (input delay) 142 ms
Bundle reduction 734 → 247 KB (hydration) 118 ms
content-visibility on tiles 104 ms

Note that no single change fixed it. INP is a grind — six changes across four modules, over a quarter. Budget accordingly, and expect the wins to be 20–40% each rather than the 60% single wins you get on LCP.


Common mistakes

Mistake Consequence
Optimizing TBT and assuming INP follows Related, not equal; TBT is load‑time only
No field attribution You'll optimize the wrong interaction
Sorting the leaderboard by p75 alone You'll fix a rare interaction
Fixing processing when the problem is input delay No movement
Adding memo everywhere Comparison cost, no gain
Ignoring interactions during load Often the worst ones
Analytics synchronously in handlers 40–150 ms per interaction
Testing on a desktop Desktop INP is usually fine; mobile is the problem

Checklist

  • Field INP collected with full attribution
  • Leaderboard sorted by count × p75
  • Sub‑part breakdown known for the top 5 interactions
  • loadState distribution checked (hydration‑era interactions)
  • Urgent/non‑urgent split applied to every major interaction
  • No analytics or logging synchronously in handlers
  • No setInterval driving React state
  • DOM node count under ~3,000 on list pages
  • Per‑interaction Playwright latency tests in CI at 4× CPU

Next: 6.3 CLS playbook