perf hacks
Core Web VitalsINP

INP - Interaction to Next Paint

Deep dive into optimizing Interaction to Next Paint for responsive user interactions

Interaction to Next Paint (INP) measures the latency of all user interactions during a page visit, focusing on the longest interaction. It replaced First Input Delay (FID) as a Core Web Vital in March 2024.

INP BREAKDOWNClickInputDelayMain thread busyProcessing TimeEvent handler runsPresentationDelayRender & paintNext PaintTarget<200msINP = Input Delay + Processing Time + Presentation Delay

Understanding INP

Score Thresholds

ScoreRatingUser Perception
≤ 200msGoodInstant response
200ms - 500msNeeds ImprovementNoticeable lag
> 500msPoorFeels broken

What Counts as an Interaction?

INP measures:

  • Clicks - Mouse clicks, taps on touch screens
  • Key presses - Keyboard input
  • Taps - Touch screen interactions

Scrolling and hovering are NOT counted as interactions for INP. These are handled by the browser's compositor thread.

INP Breakdown

INP=Input Delay+Processing Time+Presentation Delay\text{INP} = \text{Input Delay} + \text{Processing Time} + \text{Presentation Delay}
ComponentDescriptionCommon Causes
Input DelayTime before handler runsLong tasks blocking main thread
Processing TimeHandler execution timeComplex event handlers
Presentation DelayTime to paint updateLarge DOM updates, layout thrashing

Common INP Issues in Next.js

1. Heavy Event Handlers

// ❌ Bad: Blocking the main thread
function handleClick() {
  const result = expensiveCalculation(data);
  setResults(result);
}

// ✅ Good: Defer heavy work
function handleClick() {
  setIsLoading(true);

  requestIdleCallback(() => {
    const result = expensiveCalculation(data);
    setResults(result);
    setIsLoading(false);
  });
}

2. Unoptimized React Rendering

// ❌ Bad: Re-rendering entire list on filter
function ProductList({ products, filter }) {
  const filtered = products.filter(p => p.category === filter);
  return filtered.map(p => <ProductCard key={p.id} product={p} />);
}

// ✅ Good: Memoized filtering and components
const ProductCard = memo(function ProductCard({ product }) {
  return <div>{product.name}</div>;
});

function ProductList({ products, filter }) {
  const filtered = useMemo( 
    () => products.filter(p => p.category === filter),
    [products, filter]
  );

  return filtered.map(p => <ProductCard key={p.id} product={p} />);
}

3. Layout Thrashing

// ❌ Bad: Forced synchronous layout
function handleResize() {
  elements.forEach(el => {
    const height = el.offsetHeight;
    el.style.width = height + 'px';
  });
}

// ✅ Good: Batch reads and writes
function handleResize() {
  const heights = elements.map(el => el.offsetHeight);

  elements.forEach((el, i) => {
    el.style.width = heights[i] + 'px';
  });
}

4. Third-Party Script Blocking

// ❌ Bad: Blocking third-party script
<script src="https://analytics.example.com/heavy.js" />

// ✅ Good: Defer non-critical scripts
<Script
  src="https://analytics.example.com/heavy.js"
  strategy="lazyOnload"
/>

// Or load after interaction
<Script
  src="https://chat-widget.com/widget.js"
  strategy="afterInteractive"
/>

Optimization Strategies

Use React Transitions

import { useTransition, useState } from 'react';

function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  function handleSearch(e) {
    const value = e.target.value;
    setQuery(value); // Urgent: update input immediately

    startTransition(() => {
      // Non-urgent: can be interrupted
      setResults(filterResults(value));
    });
  }

  return (
    <>
      <input value={query} onChange={handleSearch} />
      {isPending ? <Spinner /> : <Results data={results} />}
    </>
  );
}

Debounce Expensive Operations

import { useDeferredValue, useMemo } from 'react';

function SearchResults({ query }) {
  // Defer the expensive value
  const deferredQuery = useDeferredValue(query);

  const results = useMemo(
    () => expensiveFilter(items, deferredQuery),
    [deferredQuery]
  );

  // Show stale data while computing
  const isStale = query !== deferredQuery;

  return (
    <div style={{ opacity: isStale ? 0.7 : 1 }}>
      {results.map(item => <Item key={item.id} {...item} />)}
    </div>
  );
}

Web Workers for Heavy Computation

workers/filter.worker.ts
self.onmessage = (e) => {
  const { items, query } = e.data;
  const filtered = items.filter(item =>
    item.name.toLowerCase().includes(query.toLowerCase())
  );
  self.postMessage(filtered);
};

// Search component
function Search({ items }) {
  const [results, setResults] = useState(items);
  const workerRef = useRef<Worker>();

  useEffect(() => {
    workerRef.current = new Worker(
      new URL('../workers/filter.worker.ts', import.meta.url)
    );
    workerRef.current.onmessage = (e) => setResults(e.data);
    return () => workerRef.current?.terminate();
  }, []);

  function handleSearch(query) {
    workerRef.current?.postMessage({ items, query });
  }

  return (/* ... */);
}

Virtualize Long Lists

import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualList({ items }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,
    overscan: 5,
  });

  return (
    <div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize() }}>
        {virtualizer.getVirtualItems().map((virtualRow) => (
          <div
            key={virtualRow.index}
            style={{
              position: 'absolute',
              top: virtualRow.start,
              height: virtualRow.size,
            }}
          >
            {items[virtualRow.index].name}
          </div>
        ))}
      </div>
    </div>
  );
}

Diagnosing INP Issues

Chrome DevTools Performance

Record an interaction Start recording → Click/type → Stop recording

Find interaction marker Look for "Interaction" in the Interactions track

Analyze the three phases Check Input Delay, Processing, and Presentation

Identify long tasks Look for tasks > 50ms blocking the main thread

Programmatic Measurement

import { onINP, type INPMetric } from 'web-vitals';

export function measureINP() {
  onINP((metric: INPMetric) => {
    console.log('INP:', metric.value, 'ms');
    console.log('Attribution:', metric.attribution);

    // Get detailed breakdown
    const { inputDelay, processingDuration, presentationDelay } =
      metric.attribution;

    sendToAnalytics({
      name: 'INP',
      value: metric.value,
      inputDelay,
      processingDuration,
      presentationDelay,
      interactionTarget: metric.attribution.interactionTarget,
    });
  });
}

React-Specific Optimizations

Use React Compiler (React 19+)

next.config.js
module.exports = {
  experimental: {
    reactCompiler: true, // Automatic memoization
  },
};

Optimize State Updates

// ❌ Bad: Multiple state updates
function handleSubmit(data) {
  setIsLoading(true);
  setError(null);
  setData(data);
}

// ✅ Good: Batched updates with reducer
const [state, dispatch] = useReducer(formReducer, initialState);

function handleSubmit(data) {
  dispatch({ type: 'SUBMIT', payload: data });
}

Avoid Inline Functions in Render

// ❌ Bad: New function every render
{items.map(item => (
  <Button onClick={() => handleClick(item.id)}>
    {item.name}
  </Button>
))}

// ✅ Good: Stable reference
const handleItemClick = useCallback((id) => {
  // handler logic
}, []);

{items.map(item => (
  <ItemButton
    key={item.id}
    item={item}
    onClick={handleItemClick}
  />
))}

INP Optimization Checklist

Event Handlers

Keep handlers under 50ms Long tasks block the main thread and delay responses.

Use startTransition for non-urgent updates Mark state updates as non-urgent so React can prioritize interactions.

Debounce rapid-fire events Prevent excessive processing on scroll, resize, or input events.

Move heavy work to Web Workers Offload computations from the main thread entirely.

Show immediate visual feedback Update UI instantly, even if data is still processing.

React Performance

Memoize expensive computations Use useMemo to avoid recalculating on every render.

Use memo() for pure components Prevent unnecessary re-renders of child components.

Virtualize long lists Only render visible items using @tanstack/react-virtual.

Avoid inline function definitions Create stable function references with useCallback.

Enable React Compiler if available Automatic memoization in React 19+.

Third-Party Scripts

Audit all third-party scripts Know exactly what's running on your page.

Use strategy="lazyOnload" for non-critical Load analytics and widgets after page is interactive.

Consider removing heavy scripts Some scripts aren't worth their performance cost.

Use Partytown for moving scripts to workers Run third-party scripts off the main thread.

DOM & Layout

Batch DOM reads and writes Avoid layout thrashing by grouping similar operations.

Use CSS containment Isolate layout calculations to specific sections.

Avoid forced synchronous layouts Don't read layout properties immediately after writes.

Keep DOM size under 1500 nodes Smaller DOM means faster updates.

Real-World Impact

OptimizationINP Improvement
React Transitions-50ms to -200ms
List virtualization-100ms to -500ms
Web Workers-100ms to -300ms
Script deferral-50ms to -150ms
Component memoization-30ms to -100ms

On this page