DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

Core Web Vitals & INP Optimization for React Apps (2026)

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
}
Enter fullscreen mode Exit fullscreen mode

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 */}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Measuring INP

npm install web-vitals
Enter fullscreen mode Exit fullscreen mode
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,
  })
})
Enter fullscreen mode Exit fullscreen mode

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} />
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

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} />
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Fix 4: Virtualize Long Lists

npm install @tanstack/react-virtual
Enter fullscreen mode Exit fullscreen mode
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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 />}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
frank_signorini profile image
Frank

With INP replacing FID, I'