Interaction to Next Paint (INP) became a Core Web Vital in March 2024, replacing First Input Delay. It’s a tougher metric — it measures the worst-case input latency across the entire user session, not just the first interaction. Sites that comfortably passed FID often fail INP. Here are the five patterns we use to fix it on FH client sites.
What INP measures
Every user input (click, tap, keypress) triggers a chain of JavaScript work followed by a render. INP measures the total time from input to the next visible paint. The site’s INP is reported as the 75th percentile of all interactions across the session. Target: under 200ms.
Why INP is hard
Modern JavaScript-heavy sites do too much work on the main thread. Every click triggers React re-renders, state updates, side effects. If any of those take longer than ~50ms, the render is delayed. The first interaction is usually fine; the 95th-percentile interaction (during scroll, while a third-party script is loading, while a video is buffering) is where INP fails.
Pattern 1: break up long tasks with yielding
A long task is any synchronous JavaScript that runs for more than 50ms. If a click handler does 200ms of work synchronously, every other interaction during those 200ms is blocked. Break long tasks into chunks that yield to the browser between them.
async function processItemsWithYield(items: Item[]) {
for (let i = 0; i < items.length; i++) {
processItem(items[i]);
if (i % 50 === 0) {
// Yield to the browser every 50 items
await new Promise((r) => setTimeout(r, 0));
}
}
}
// Better: use scheduler.yield() (modern API)
async function processItemsWithSchedulerYield(items: Item[]) {
for (const item of items) {
processItem(item);
if ("scheduler" in window && "yield" in (window as any).scheduler) {
await (window as any).scheduler.yield();
}
}
}Pattern 2: defer heavy work with startTransition
React 18+ ships `useTransition` and `startTransition` to mark state updates as non-urgent. The user input gets handled immediately; the heavy work happens during browser idle time. INP drops because the render isn’t blocked on the heavy work.
"use client";
import { useState, useTransition } from "react";
function SearchBox() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Item[]>([]);
const [isPending, startTransition] = useTransition();
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value); // urgent — input must respond immediately
startTransition(() => {
setResults(searchExpensively(e.target.value)); // non-urgent
});
}
return <input value={query} onChange={handleChange} />;
}Pattern 3: debounce expensive input handlers
If a search input triggers an API call on every keystroke, fast typing causes a backlog of pending requests and frequent state updates. Debounce: wait until the user pauses (300ms) before triggering the expensive work.
import { useEffect, useState } from "react";
function useDebouncedValue<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return debounced;
}Pattern 4: lazy-load heavy third-party scripts
Chat widgets, tag managers, analytics scripts can each block the main thread for hundreds of ms when they execute. Don’t load them at page-load time. Load on idle (`requestIdleCallback`) or on user interaction (first scroll, first click).
// Next.js Script with strategy="lazyOnload" defers until the page is idle
<Script
src="https://intercom.io/widget.js"
strategy="lazyOnload"
/>Pattern 5: avoid layout-thrashing in event handlers
Reading layout properties (offsetHeight, getBoundingClientRect) inside a tight loop forces the browser to recalculate layout, which is slow. Batch reads, then batch writes. Or use ResizeObserver / IntersectionObserver instead of polling layout properties.
Measuring INP in production
Lighthouse reports lab INP, which is an estimate. Real field INP comes from Chrome’s CrUX report (visible in Search Console) or via the Web Vitals JS library that you embed and ship to your analytics.
import { onINP } from "web-vitals";
onINP((metric) => {
// Send to analytics
fetch("/api/inp", {
method: "POST",
body: JSON.stringify({
value: metric.value,
id: metric.id,
entries: metric.entries.length,
}),
});
});INP-killer libraries we’ve removed
- framer-motion: replaced with native View Transitions where possible. INP drops 60-100ms on heavy pages.
- Heavy chart libraries running on the main thread during initial render. Lazy-load or render to canvas instead.
- Polyfills for features the user’s browser already supports. Modern browsers don’t need them.
- Old jQuery plugins running on every page. Anyone still shipping these in 2026 is paying an INP tax.
Component-level optimization
- Memoize expensive render functions with useMemo where the recomputation cost is real.
- Wrap callback props in useCallback to prevent unnecessary child re-renders.
- Use React.memo on leaf components that re-render often with the same props.
- Avoid context for state that changes often — Context updates re-render every consumer.
When INP fails on mobile but not desktop
Common. Mobile CPUs are 3-5x slower than desktop. Any code that runs in 50ms on desktop can take 200ms+ on a low-end Android. Always test on a real mid-tier mobile device, not just a desktop with throttled CPU.
When 200ms isn’t the right target
Some interactions genuinely take longer than 200ms even with perfect optimization (a search that returns thousands of results, a complex calculation). Show optimistic UI: render the result skeleton immediately, fill it in asynchronously. The user perceives sub-100ms response even though the underlying work takes longer.
How this lands across FH client work
Every FH client site ships with INP under 150ms in field data. The combination of: server-by-default components (less client JS), React Compiler (auto-memoization), lazy-loaded third-party scripts, debounced inputs, and native CSS animations — adds up to consistently fast interactions. If your site’s INP is in the ‘needs improvement’ or ‘poor’ bucket, book a consultation — the remediation usually ships in a single sprint.