Fix INP in Next.js
INP is the Core Web Vital that Next.js apps are most likely to fail, particularly client-heavy SPAs with large component trees. React 18's concurrent rendering features -- startTransition, useDeferredValue, and React Server Components -- are the primary tools for bringing INP under the 200ms threshold in Next.js. This guide shows exactly how to apply them.
Expected results
Before
520ms
INP (Poor) -- synchronous state updates, no code splitting, large component tree re-renders
After
140ms
INP (Good) -- startTransition, Server Components, deferred non-visual work, virtualized lists
Step-by-step fix
Use startTransition for expensive state updates
When a user interaction triggers a state update that causes an expensive re-render, wrapping it in startTransition tells React it is non-urgent. React will start rendering the update but interrupt it if the user interacts again, keeping the UI immediately responsive. The transition eventually completes in the background.
'use client';
import { useState, startTransition, useDeferredValue } from 'react';
export function SearchPage({ allItems }: { allItems: Item[] }) {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
// Filtering is expensive but not urgent -- defer it
const filtered = allItems.filter(item =>
item.name.toLowerCase().includes(deferredQuery.toLowerCase())
);
function handleInput(e: React.ChangeEvent<HTMLInputElement>) {
// The input updates immediately (urgent)
setQuery(e.target.value);
// No need to wrap setQuery -- useDeferredValue handles the filtering
}
return (
<>
<input value={query} onChange={handleInput} placeholder="Search..." />
<ResultsList items={filtered} />
</>
);
}
// With explicit startTransition for navigation or complex updates
import { startTransition, useTransition } from 'react';
export function FilterBar({ onFilter }: { onFilter: (f: Filter) => void }) {
const [isPending, startTransition] = useTransition();
function handleChange(filter: Filter) {
startTransition(() => {
onFilter(filter); // Non-urgent: can be interrupted
});
}
return (
<div style={{ opacity: isPending ? 0.7 : 1 }}>
{/* Filter controls */}
</div>
);
}
Code-split with next/dynamic
Large JavaScript bundles create long tasks on the main thread, causing input delay -- the first phase of INP. Use next/dynamic to lazy-load heavy components. On a typical Next.js app, moving charts, editors, maps, and modals to dynamic imports can reduce the initial JS bundle by 30-50%.
import dynamic from 'next/dynamic';
// Heavy chart library -- only loaded when the chart is visible
const RevenueChart = dynamic(
() => import('@/components/RevenueChart'),
{
loading: () => <div className="h-64 bg-surface animate-pulse rounded" />,
ssr: false,
}
);
// Rich text editor -- only loaded when the user opens a form
const RichTextEditor = dynamic(
() => import('@/components/Editor'),
{ ssr: false }
);
// Map component
const MapView = dynamic(() => import('@/components/MapView'), { ssr: false });
export default function Dashboard() {
return (
<main>
<h1>Dashboard</h1>
<RevenueChart />
<MapView />
</main>
);
}
Move non-interactive content to Server Components
React Server Components (default in the App Router) render on the server and send no JavaScript to the browser. Moving headers, footers, product descriptions, article bodies, and any non-interactive UI to Server Components directly reduces the client JS bundle and eliminates unnecessary hydration -- both of which reduce main thread contention and improve INP.
// app/product/[id]/page.tsx
// Server Component by default -- zero client JS
import { AddToCartButton } from './AddToCartButton'; // 'use client'
async function getProduct(id: string) {
return fetch(`/api/products/${id}`).then(r => r.json());
}
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
return (
<article>
{/* All of this is zero-JS server-rendered HTML */}
<h1>{product.name}</h1>
<p>{product.description}</p>
<ProductImages images={product.images} />
<ProductSpecs specs={product.specs} />
{/* Only the interactive button is a Client Component */}
<AddToCartButton productId={product.id} price={product.price} />
</article>
);
}
// components/AddToCartButton.tsx
'use client'; // Only this component ships JS to the browser
import { useState } from 'react';
export function AddToCartButton({ productId, price }: Props) {
const [added, setAdded] = useState(false);
return (
<button onClick={() => setAdded(true)}>
{added ? 'Added to cart' : `Add to cart -- $${price}`}
</button>
);
}
Defer non-visual work in event handlers
Event handlers that do both visual updates (state changes) and non-visual work (analytics, logging, local storage writes) make INP pay for the non-visual work. Defer non-visual tasks with requestIdleCallback or a setTimeout(0) fallback so INP only measures the time to the visual update.
'use client';
function AddToCartButton({ productId }: { productId: string }) {
const [added, setAdded] = useState(false);
function handleClick() {
// Urgent: visual update happens immediately
setAdded(true);
// Non-urgent: deferred so they don't block the next paint
const defer = (fn: () => void) =>
'requestIdleCallback' in window
? requestIdleCallback(fn)
: setTimeout(fn, 0);
defer(() => {
analytics.track('add_to_cart', { productId });
localStorage.setItem('lastAdded', productId);
syncCartToServer(productId);
});
}
return (
<button onClick={handleClick}>
{added ? 'Added' : 'Add to cart'}
</button>
);
}
Virtualize long lists
Rendering hundreds or thousands of DOM nodes from a list component creates an enormous presentation delay when any part of the component tree re-renders. Use @tanstack/react-virtual to render only the items visible in the viewport. This reduces DOM size, speeds up style recalculation, and dramatically improves INP for components that render near large lists.
'use client';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
export function ProductList({ products }: { products: Product[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: products.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // estimated row height in px
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: '600px', overflowY: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize() + 'px', position: 'relative' }}>
{virtualizer.getVirtualItems().map(item => (
<div
key={products[item.index].id}
style={{
position: 'absolute',
top: item.start + 'px',
width: '100%',
height: item.size + 'px',
}}
>
<ProductRow product={products[item.index]} />
</div>
))}
</div>
</div>
);
}
Quick checklist
-
Expensive state updates in event handlers wrapped in
startTransition -
Heavy components (charts, editors, maps) loaded with
next/dynamic -
Non-interactive UI in Server Components (no
'use client'directive) -
Analytics and logging deferred with
requestIdleCallback - Lists with 100+ items use virtualization
-
Third-party scripts use
next/scriptwithstrategy="lazyOnload"
Frequently asked questions
The most common causes are: large JavaScript bundles creating long tasks (input delay), synchronous state updates triggering expensive re-renders of large component trees (processing time), large DOM sizes slowing style recalculation after interactions (presentation delay), and third-party scripts running long tasks during user interactions.
startTransition marks a state update as non-urgent. React starts rendering it but will pause and handle any new urgent input first. INP is measured from input to the next visual paint -- for the urgent visual response, that paint happens quickly. The expensive transition finishes in the background, eventually showing the result without having blocked the immediate interaction response.
Yes, indirectly. Server Components send no JavaScript to the browser. Less JS means shorter long tasks, which means less input delay -- the first phase of INP. If you have 500KB of components that could be Server Components, converting them eliminates the parsing, compilation, and execution of that code, directly reducing the chance of a long task blocking user input.
Add onINP from web-vitals/attribution to your layout component and send the data to your analytics platform. The attribution object tells you the interaction type, target element, and the breakdown across the three INP phases (input delay, processing, presentation). Field data is more reliable than lab data for INP since it captures real usage patterns.
Use startTransition when you own the state update in an event handler. Use useDeferredValue when you receive a value as a prop or state and want to defer its effect on an expensive child render -- typically in search/filter scenarios where the input value updates instantly but the filtered results render at lower priority.
Use Chrome DevTools Performance panel with CPU throttling (4x slowdown) to simulate mid-range mobile devices. Interact with the page (click buttons, type in inputs, open menus) and look for long tasks in the flame chart. The Web Vitals Chrome Extension shows real-time INP scores as you interact. For Nextjs, pay attention to hydration-related interaction delays.
Related resources
Complete INP Guide
Deep dive into the three INP phases, measurement tools, and optimization techniques.
FixFix CLS in Next.js
Eliminate layout shifts from images, fonts, hydration mismatches, and animations.
FixFix LCP in Next.js
Optimize image priority, ISR, font loading, and bundle size for faster paint.
Continue learning
Complete INP Guide
Deep dive into INP -- thresholds, measurement, and optimization strategies.
FixFix LCP in Next.js
Related performance optimization for the same framework.
FixFix CLS in Next.js
Related performance optimization for the same framework.
ToolCWV Score Explainer
Enter your scores for personalized fix recommendations.