Tutorials
|stacknotice.com
12 min left|
0%
|2,400 words
Tutorials

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

INP replaced FID as a Core Web Vital. Here's how to measure it, why React apps fail it, and the fixes — useTransition, scheduler.yield(), virtualization, and more.

C
Carlos Oliva
Software Developer
June 30, 202612 min read
Share:
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.

This guide covers what INP actually measures, how to find the interactions failing it, and the concrete fixes for React.

What INP Measures (and Why It's Harder Than FID)

FID measured only the delay before the browser could start processing the first user interaction. It ignored how long the processing actually took.

INP measures the full visual response time for interactions: from when the user clicks, taps, or presses a key to when the browser has painted the next frame. It tracks all interactions throughout the session and reports the worst one (with a small outlier exclusion).

Thresholds:

  • Good: under 200ms
  • Needs improvement: 200–500ms
  • Poor: over 500ms

A button click that triggers a 300ms React re-render fails INP. A form field that runs 400ms of validation on each keystroke fails INP. These are common React patterns that FID would have passed because FID only measured input delay — not the time for the actual work.

What Causes High INP in React Apps

Long event handlers

The main thread can only paint after a task finishes. If a click handler does 300ms of synchronous work — state updates, calculations, DOM mutations — the user sees nothing change for 300ms.

// ❌ 300ms of sync work before the browser can paint
function handleFilterChange(value: string) {
  setFilter(value)
  const filtered = heavyFilter(allProducts, value) // 200ms
  setFilteredProducts(filtered)
  updateAnalytics(value) // 80ms
}

Large React re-renders

Updating state in response to an interaction triggers a re-render. If the component tree is large or components are expensive, that re-render blocks the main thread.

// ❌ Input change re-renders the whole ProductGrid — 250ms on large lists
function SearchPage() {
  const [query, setQuery] = useState('')
  const results = products.filter(p => p.name.includes(query)) // unoptimized
 
  return (
    <>
      <input onChange={(e) => setQuery(e.target.value)} />
      <ProductGrid items={results} /> {/* 500 items, expensive */}
    </>
  )
}

Hydration blocking

During hydration, React replays event listeners and reconciles the virtual DOM with the server-rendered HTML. On heavy pages, this can take 200–500ms, blocking all interactions until it completes. Any interaction during this window fails INP.

Third-party scripts

Analytics, chat widgets, and ad scripts run on the main thread. A third-party script that takes 100ms doesn't show up in your React profiler, but it contributes to INP.

Measuring INP

web-vitals library

npm install web-vitals
// lib/vitals.ts
import { onINP, onLCP, onCLS } from 'web-vitals'
 
export function reportWebVitals() {
  onINP((metric) => {
    console.log('INP:', metric.value, 'ms', metric.rating)
    // Send to your analytics
    // metric.attribution has the element and event type that caused it
    analytics.track('web_vital', {
      name: metric.name,
      value: metric.value,
      rating: metric.rating,
      element: metric.attribution?.interactionTarget,
      event_type: metric.attribution?.interactionType,
    })
  })
}

The attribution object tells you which element and what interaction type caused the bad INP — essential for debugging.

// In Next.js app layout
'use client'
import { useEffect } from 'react'
import { reportWebVitals } from '@/lib/vitals'
 
export function VitalsProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    reportWebVitals()
  }, [])
  return <>{children}</>
}

Chrome DevTools

  1. Open Performance panel → record while interacting
  2. Look for Long Tasks (red blocks over 50ms)
  3. In the Interactions track, find interactions with long presentation delays

The INP badge in DevTools (Chrome 117+) shows INP for the current session in real time.

LoAF (Long Animation Frame) API

The Long Animation Frame API is newer than Long Tasks and gives more detail:

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      console.log('Long frame:', entry.duration, 'ms')
      console.log('Scripts:', entry.scripts.map(s => ({
        name: s.sourceURL,
        duration: s.duration,
      })))
    }
  }
})
 
observer.observe({ type: 'long-animation-frame', buffered: true })

LoAF tells you which script caused the long frame — useful for attributing blame to third-party code.

Fix 1: useTransition for Non-Urgent Updates

useTransition marks a state update as low-priority. React processes the urgent update (showing the input value) first, paints, then processes the transition update (filtering the list). The user sees the input respond instantly even if the list update takes 300ms.

'use client'
 
import { useTransition, useState, useMemo } from 'react'
 
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 — show input value immediately
    startTransition(() => {
      setDeferredQuery(value) // deferred — filter 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} />
    </>
  )
}

The input responds in the same frame. The filtered list updates when React gets to it. INP is measured on the input's visual response — which is now a single frame.

Fix 2: useDeferredValue

When the slow work is in a child component rather than in your state update, useDeferredValue gives it a stale value to render with, then updates it after the urgent paint:

import { useDeferredValue, memo } from 'react'
 
// Wrap the expensive component in memo so it can bail out
const ExpensiveList = memo(function ExpensiveList({ query }: { query: string }) {
  const results = heavyFilter(items, query)
  return <ul>{results.map(item => <li key={item.id}>{item.name}</li>)}</ul>
})
 
export function SearchPage() {
  const [query, setQuery] = useState('')
  const deferredQuery = useDeferredValue(query) // lags behind query
 
  return (
    <>
      <input onChange={(e) => setQuery(e.target.value)} />
      {/* Renders with old query until React can update without blocking */}
      <ExpensiveList query={deferredQuery} />
    </>
  )
}

Fix 3: scheduler.yield()

scheduler.yield() is a newer browser API that lets you yield control back to the browser in the middle of a long task, allowing it to paint or process other events:

async function processLargeDataset(items: Item[]) {
  const results = []
 
  for (let i = 0; i < items.length; i++) {
    results.push(expensiveProcess(items[i]))
 
    // Yield every 50 items — let the browser paint between chunks
    if (i % 50 === 0) {
      await scheduler.yield()
    }
  }
 
  return results
}

This is particularly useful for operations that can't be moved to a Web Worker (they need DOM access or React state) but are too heavy to run as a single synchronous task.

Check support before using:

if ('scheduler' in window && 'yield' in window.scheduler) {
  await scheduler.yield()
} else {
  // Fallback: setTimeout(resolve, 0)
  await new Promise(resolve => setTimeout(resolve, 0))
}

Fix 4: Virtualize Long Lists

Rendering 500 list items means 500 DOM nodes. Even a simple list with {items.map(...)} becomes expensive to reconcile. Virtualization renders only the visible items:

npm install @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
 
export function VirtualProductList({ products }: { products: Product[] }) {
  const parentRef = useRef<HTMLDivElement>(null)
 
  const virtualizer = useVirtualizer({
    count: products.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 72, // estimated row height in px
  })
 
  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%',
              height: `${virtualItem.size}px`,
            }}
          >
            <ProductCard product={products[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  )
}

500 items → ~10 rendered at a time. Re-render cost drops by 98%.

Fix 5: Code Split Heavy Interactions

If a click opens a heavy modal or loads a heavy component, load the code only when the user actually clicks:

import dynamic from 'next/dynamic'
 
const HeavyAnalyticsDashboard = dynamic(
  () => import('@/components/analytics-dashboard'),
  { loading: () => <DashboardSkeleton /> }
)
 
export function AnalyticsButton() {
  const [open, setOpen] = useState(false)
 
  return (
    <>
      <button
        // Preload on hover so the chunk is ready by the time they click
        onMouseEnter={() => import('@/components/analytics-dashboard')}
        onClick={() => setOpen(true)}
      >
        View Analytics
      </button>
      {open && <HeavyAnalyticsDashboard />}
    </>
  )
}

The click handler itself is now just setOpen(true) — essentially instant.

Fix 6: Move Work Off the Main Thread

For computationally heavy work that doesn't need React state, move it to a Web Worker:

// workers/filter.worker.ts
self.onmessage = ({ data: { items, query } }) => {
  const results = items.filter(item =>
    item.name.toLowerCase().includes(query.toLowerCase()) &&
    item.category === query.category
  )
  self.postMessage(results)
}
 
// In your component
const worker = new Worker(new URL('../workers/filter.worker.ts', import.meta.url))
 
function handleSearch(query: string) {
  worker.postMessage({ items: allItems, query })
  worker.onmessage = ({ data }) => setResults(data)
}

Web Workers run on a separate thread entirely — zero impact on the main thread, zero impact on INP.

Third-Party Scripts: Use next/script

import Script from 'next/script'
 
// ❌ Regular script tag blocks the main thread
<script src="https://analytics.example.com/script.js" />
 
// ✅ next/script with strategy="lazyOnload" loads after page is interactive
<Script
  src="https://analytics.example.com/script.js"
  strategy="lazyOnload"
/>
 
// Or "afterInteractive" — loads after hydration, before lazy
<Script
  src="https://chat-widget.example.com/widget.js"
  strategy="afterInteractive"
/>

lazyOnload scripts don't run until the browser is idle — they can't contribute to INP during the critical interaction window.

INP Checklist

High INP from click/tap:
├─ Is the event handler doing sync work over 50ms?
│   └─ Move non-urgent updates into startTransition()
├─ Is a large React tree re-rendering?
│   ├─ useDeferredValue on the slow child
│   ├─ memo() + stable references
│   └─ Virtualize if it's a long list
├─ Is a heavy component loading on click?
│   └─ next/dynamic with preload on hover
└─ Is it third-party code?
    └─ next/script with lazyOnload or afterInteractive

High INP from input (keypress):
├─ Debounce expensive operations (not state — that kills UX)
├─ useTransition for the filtering/searching update
└─ useDeferredValue if the slow work is in a child

INP good in dev but bad in production:
└─ Profile on real hardware (mobile, throttled CPU)
   DevTools → Performance → CPU 4x slowdown

The main lever in React apps is useTransition — it directly maps the "urgent vs non-urgent" distinction onto the interaction timeline. Most React INP failures are large re-renders triggered by user input that could be deferred. Start there, measure before and after with the web-vitals library, and only reach for Web Workers if the work genuinely can't be made fast enough on the main thread.

For the broader Next.js performance picture, see the Next.js performance and Lighthouse guide and React anti-patterns that kill performance.

#performance#react#nextjs#webvitals#javascript
Share:
C
Carlos Oliva
Software Developer · stacknotice.com

Software developer with hands-on experience building production apps with React, Next.js, Angular, TypeScript, and Spring Boot. I write practical guides on Claude Code, AI tools, and modern web development — covering the decisions and trade-offs that senior-level tutorials actually explain.

More about Carlos

Enjoyed this article?

Get weekly insights on Claude Code, React, and AI tools — practical guides for developers who build real things.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.