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;
A3 — Timers and intervals¶
A setInterval doing work every 100 ms is a guaranteed input‑delay generator.
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.
→ 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)¶
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¶
1. Uncontrolled input, oruseDeferredValue 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)¶
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 (nokey change)
Add to cart¶
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)¶
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
-
loadStatedistribution checked (hydration‑era interactions) - Urgent/non‑urgent split applied to every major interaction
- No analytics or logging synchronously in handlers
- No
setIntervaldriving 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