Performance
Mar 2026·7 min read

Optimizing Interaction to Next Paint (INP) in Complex React & Next.js Apps

A practical guide to diagnosing interaction latency, using React concurrency APIs responsibly, reducing main-thread work, and validating INP with lab and field data.

LEP
Lelianto Eko PradanaAI & Full Stack Engineer · Indonesia

Introduction

Interaction to Next Paint (INP) replaced First Input Delay (FID) as a Core Web Vital. While FID only measured the delay before the browser began processing the *first* user interaction, INP measures the overall responsiveness of your application by calculating the latency of *all* user clicks, taps, and keyboard inputs throughout the page lifecycle.

In complex React and Next.js applications, poor INP is usually a symptom of work competing with the interaction on the main thread: a large render, expensive event handler, layout work, or a third-party script. The goal is to keep the interaction below 200ms, then verify the result with real-user data.

A practical workflow

  1. Reproduce the slow interaction in a production-like build.
  2. Record a performance trace and identify the longest task in the interaction.
  3. Reduce the work, move it off the critical path, or split it into interruptible chunks.
  4. Validate the same interaction with field data after release.

1. Deconstructing the INP Lifecycle

When a user interacts with a web component (e.g., clicking a filter or typing in an input field), INP measures three distinct phases:

  1. Input Delay: The time between the user action and the execution of event handlers.
  2. Processing Time: The time spent executing JavaScript event handlers.
  3. Presentation Delay: The time spent by the browser recalculating styles, running layout algorithms, and painting the new frame to the screen.
[ User Interaction ] ──> ( Input Delay ) ──> ( Processing Time ) ──> ( Presentation Delay ) ──> [ Frame Painted ]

2. Yielding Long Tasks with `scheduler.yield()`

When an event handler triggers heavy computations (such as filtering large data arrays or re-indexing search trees), executing everything synchronously blocks the browser from painting visual feedback.

Instead of running long monolithic loops, break execution into chunked tasks using scheduler.yield() (or fallback to setTimeout / requestIdleCallback):

typescript
async function processDataChunks<T>(items: T[], processFn: (item: T) => void) {
  for (let i = 0; i < items.length; i++) {
    processFn(items[i]);
    
    // Yield execution back to main thread every 50 items
    if (i % 50 === 0 && 'scheduler' in window && 'yield' in window.scheduler) {
      await (window as any).scheduler.yield();
    }
  }
}

3. Non-Blocking State Updates with `useTransition`

In React 18+, state updates marked with startTransition are treated as non-urgent. This allows urgent inputs (like typing or button clicks) to interrupt background rendering:

tsx
import { useState, useTransition } from "react";

export function DataFilterList({ rawItems }: { rawItems: Item[] }) {
  const [query, setQuery] = useState("");
  const [filteredList, setFilteredList] = useState(rawItems);
  const [isPending, startTransition] = useTransition();

  const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const nextQuery = e.target.value;
    // Urgent update: keep the text input immediately responsive
    setQuery(nextQuery);

    // Non-urgent update: allow UI to paint typing before running heavy filter
    startTransition(() => {
      const results = heavyFilterAlgorithm(rawItems, nextQuery);
      setFilteredList(results);
    });
  };

  return (
    <div>
      <input value={query} onChange={handleSearchChange} placeholder="Filter items..." />
      {isPending && <span className="spinner">Updating list...</span>}
      <ListView items={filteredList} />
    </div>
  );
}

4. Measure Before and After

Use Lighthouse or the Chrome Performance panel to find a likely cause, but do not treat a lab score as the final verdict. Capture INP attribution in production and group samples by route and interaction target. Also test on a mid-range mobile device: a trace that looks harmless on a developer laptop can become a long task on a phone.

5. Key Takeaways for Production Architecture

  1. Avoid Layout Thrashing: Never read DOM properties (like offsetHeight or getBoundingClientRect) immediately after writing DOM styles inside input handlers.
  2. Profile Real User Monitoring (RUM): Use the web-vitals SDK to capture INP metrics in production and log attribution data (target element, event type, load state).
  3. Defer Non-Critical Work: Move analytics and log transmission behind requestIdleCallback() with a timeout fallback. Use a Web Worker when the computation itself—not just the network request—is expensive.
  4. Set a budget: Treat p75 INP under 200ms as the target and investigate interactions above 500ms urgently.