In March 2024, Google replaced FID (First Input Delay) with INP (Interaction to Next Paint) as a Core Web Vital. INP is harder to pass than FID — it measures all interactions throughout the page's life, not just the first one — and React apps have specific patterns that tank it.
What INP Measures
FID measured only the delay before processing started — it ignored how long the work took.
INP measures the full visual response time: from user click/tap/keypress to when the browser paints the next frame. It tracks ALL interactions throughout the session and reports the worst one.
Thresholds:
- Good: < 200ms
- Needs improvement: 200–500ms
- Poor: > 500ms
A button click that triggers a 300ms React re-render fails INP. FID would have passed it.
What Causes High INP in React Apps
Long event handlers — the main thread can't paint until a task finishes:
// ❌ 300ms of sync work before the browser paints
function handleFilterChange(value: string) {
setFilter(value)
const filtered = heavyFilter(allProducts, value) // 200ms
setFilteredProducts(filtered)
updateAnalytics(value) // 80ms
}
Large React re-renders — updating state triggers full tree reconciliation:
// ❌ Input change re-renders 500 ProductCard components
function SearchPage() {
const [query, setQuery] = useState('')
const results = products.filter(p => p.name.includes(query))
return (
<>
<input onChange={(e) => setQuery(e.target.value)} />
<ProductGrid items={results} /> {/* 500 items, expensive */}
</>
)
}
Measuring INP
npm install web-vitals
import { onINP } from 'web-vitals'
onINP((metric) => {
console.log('INP:', metric.value, 'ms', metric.rating)
// metric.attribution tells you WHICH element caused it
analytics.track('inp', {
value: metric.value,
rating: metric.rating,
element: metric.attribution?.interactionTarget,
type: metric.attribution?.interactionType,
})
})
Fix 1: useTransition
Marks non-urgent updates as deferrable — the input responds immediately, the list updates after paint:
export function SearchPage({ products }: { products: Product[] }) {
const [query, setQuery] = useState('')
const [deferredQuery, setDeferredQuery] = useState('')
const [isPending, startTransition] = useTransition()
function handleSearch(value: string) {
setQuery(value) // urgent — paint immediately
startTransition(() => {
setDeferredQuery(value) // deferred — after paint
})
}
const filtered = useMemo(
() => products.filter(p => p.name.toLowerCase().includes(deferredQuery.toLowerCase())),
[products, deferredQuery]
)
return (
<>
<input value={query} onChange={(e) => handleSearch(e.target.value)}
className={isPending ? 'opacity-70' : ''} />
<ProductGrid items={filtered} />
</>
)
}
Fix 2: useDeferredValue
When the slow work is in a child component:
export function SearchPage() {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
return (
<>
<input onChange={(e) => setQuery(e.target.value)} />
{/* Renders with stale query until React can update safely */}
<ExpensiveList query={deferredQuery} />
</>
)
}
Fix 3: scheduler.yield()
Yields back to the browser in the middle of a long task:
async function processLargeDataset(items: Item[]) {
const results = []
for (let i = 0; i < items.length; i++) {
results.push(expensiveProcess(items[i]))
if (i % 50 === 0) {
await scheduler.yield() // let the browser paint between chunks
}
}
return results
}
Fix 4: Virtualize Long Lists
npm install @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual'
export function VirtualProductList({ products }: { products: Product[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: products.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
})
return (
<div ref={parentRef} className="h-96 overflow-auto">
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div key={virtualItem.key}
style={{ position: 'absolute', top: `${virtualItem.start}px`, width: '100%' }}>
<ProductCard product={products[virtualItem.index]} />
</div>
))}
</div>
</div>
)
}
500 items → ~10 rendered. Re-render cost drops 98%.
Fix 5: Code Split Heavy Interactions
const HeavyDashboard = dynamic(() => import('@/components/analytics-dashboard'))
function AnalyticsButton() {
const [open, setOpen] = useState(false)
return (
<>
<button
onMouseEnter={() => import('@/components/analytics-dashboard')} // preload
onClick={() => setOpen(true)}
>
View Analytics
</button>
{open && <HeavyDashboard />}
</>
)
}
Click handler is now just setOpen(true) — instant.
Fix 6: next/script for Third-Party Code
import Script from 'next/script'
{/* ❌ Blocks main thread */}
<script src="https://analytics.example.com/script.js" />
{/* ✅ Loads after page is interactive */}
<Script src="https://analytics.example.com/script.js" strategy="lazyOnload" />
INP Decision Tree
High INP?
├─ From click/tap:
│ ├─ Long event handler → startTransition() for non-urgent updates
│ ├─ Large re-render → useDeferredValue + memo()
│ ├─ Heavy component loading → next/dynamic + preload on hover
│ └─ Third-party script → next/script lazyOnload
│
└─ From keypress:
├─ useTransition for the filter/search update
└─ useDeferredValue if slow work is in a child component
INP good in dev, bad in production:
└─ Profile on real hardware — DevTools CPU 4x throttle
The main lever: useTransition. Most React INP failures are large re-renders triggered by user input that can be deferred. Measure before and after with the web-vitals library.
Full article at stacknotice.com/blog/web-vitals-inp-optimization-2026
Top comments (1)
With INP replacing FID, I'