INP Next.js 14+ / React 18+

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.

Key fact: INP replaced FID as a Core Web Vital in March 2024. FID only measured input delay for the first interaction. INP measures the complete latency of all interactions throughout the page's lifetime. Apps that passed FID often fail INP because client-side routing and complex state updates weren't measured before.

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.

TypeScript -- startTransition for search
'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%.

TypeScript -- Dynamic imports for heavy components
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.

TypeScript -- Narrow client boundaries
// 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.

TypeScript -- Lean event handlers
'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.

TypeScript -- Virtualized list with TanStack Virtual
'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/script with strategy="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.

Continue learning