# Optimizing Interaction to Next Paint for Frontend Developers

Source: https://www.egnworks.com/blog/optimizing-interaction-to-next-paint-for-frontend-developers  
Author: Jacob Val  
Published: 2026-07-04  
Updated: 2026-07-07  
Category: Frontend Architecture  
Tags: Core Web Vitals, React, JavaScript

> Learn how to optimize Interaction to Next Paint (INP) and pass Google Core Web Vitals. Covers INP phases, long task detection, scheduler.yield, React optimization patterns, and real user monitoring strategies.

---

## Introduction

When Google replaced First Input Delay with Interaction to Next Paint as a Core Web Vital in March 2024, many frontend teams discovered that their previously passing performance scores suddenly had a new failure mode. FID measured how long the browser waited before it could even begin processing a user's first interaction. INP measures something more demanding: the full latency of every interaction a user has with the page, from the moment they tap or click to the moment the browser paints the visual response.

The distinction matters because users interact with pages throughout their visit, not just at load time. A site can have excellent FID and terrible INP if heavy JavaScript runs during interactions rather than during page load. According to data from Google's Chrome User Experience Report, 43% of sites fail the 200ms INP threshold as of 2026. Improving INP from 500ms to 200ms correlates with a 22% improvement in user engagement metrics including time on page and return visits. For e commerce sites, the impact on conversion is direct: every interaction in a checkout flow that responds slowly increases abandonment.

This guide explains what INP measures, how to diagnose failures, and the specific architectural and code level patterns that bring scores into the passing range.

## What INP Actually Measures

INP captures the full latency of every discrete user interaction on a page during a session, including mouse clicks, tap events, and keyboard input. It reports the value at the 75th percentile across all interactions, meaning the score represents the experience of most real users, not the best case. Scrolling and hover events do not count because they are handled by the browser's compositor thread and do not block the main thread.

The thresholds Google uses for ranking signals:

200ms or below is considered Good

200ms to 500ms Needs Improvement

Above 500ms is Poor

Every INP value is the sum of three sequential phases. Understanding them separately is essential because the fix for each phase is different.

### Phase 1: Input Delay

The time between when the user interacts and when the browser begins executing the event handlers for that interaction. Input delay is caused by the main thread being occupied with other work at the moment the user acts. The most common causes are long JavaScript tasks executing during page load or immediately after, third party script execution, and heavy timers running on the main thread. Research from CoreDash shows interactions during the page loading phase have an average INP of 132ms just from input delay, compared to 50ms for interactions after load. Fixing the loading phase is often the highest leverage INP optimization available.

### Phase 2: Processing Time

The time it takes for the event handler JavaScript to execute. This is the phase developers have the most direct control over. An event handler that does expensive DOM queries, complex calculations, or synchronous network calls will extend processing time significantly. Long synchronous tasks here block the main thread from processing anything else, including other user interactions.

### Phase 3: Presentation Delay

The time from when the event handler finishes to when the browser paints the updated frame. This phase surprises many developers because a fast event handler does not guarantee a fast INP. If the page has a large DOM with thousands of nodes, complex CSS that triggers expensive reflows, or layout thrashing inside event handlers, the browser's render pipeline itself becomes the bottleneck. A handler can complete in 5ms but the presentation delay can add another 200ms if the rendering work is expensive.

## Diagnosing INP Problems

INP is a field metric, which means the most accurate data comes from real users in production rather than lab tests. The tools for measuring it properly:

### Chrome DevTools Performance Panel

Record a trace during the interactions you want to diagnose. Look for long tasks in the Main thread lane, tasks shown in red that exceed 50ms. The Long Animation Frames (LoAF) API, now surfaced prominently in DevTools, shows which scripts caused frames to take too long with exact attribution down to the function and line number.

### Web Vitals Extension

Chrome's Web Vitals extension shows the INP value for each interaction as you perform it in real time. This is the fastest way to identify which specific interactions on a page are problematic before recording a full trace.

### Real User Monitoring

Lab testing will miss many real world INP problems because user behavior is unpredictable. Integrating the web vitals JavaScript library into your application sends INP data from actual users, segmented by page and interaction type, to your analytics platform. This is the only way to catch INP failures that only appear on specific devices, in specific user flows, or under specific network conditions.

```
import { onINP } from "web-vitals";

onINP(({ value, rating, entries }) => {
  // Send to your analytics endpoint
  analytics.track("web_vitals", {
    metric: "INP",
    value,
    rating, // "good", "needs-improvement", or "poor"
    // entries contains the individual interaction details
    slowestInteraction: entries[0]?.name,
  });
});
```

## Fixing Input Delay: Reducing Main Thread Contention

The most impactful changes for reducing input delay involve reducing the amount of JavaScript running on the main thread during and after page load.

### Break Up Long Tasks

Any synchronous JavaScript task that exceeds 50ms is a long task that can cause input delay. The browser cannot process user interactions until the current task completes. The fix is to yield control back to the main thread at regular intervals, allowing the browser to process queued interactions between chunks of work.

```js
// Without yielding: one long task that blocks all interactions
async function processLargeDataset(items) {
  const results = [];
  for (const item of items) {
    results.push(await heavyTransform(item));
  }
  return results;
}

// With yielding: browser can handle interactions between chunks
async function processLargeDataset(items) {
  const results = [];
  for (let i = 0; i < items.length; i++) {
    results.push(await heavyTransform(items[i]));

    // Yield to the main thread every 5 items
    if (i % 5 === 0) {
      await scheduler.yield();
    }
  }
  return results;
}
```

`scheduler.yield()` is the modern API for yielding to the main thread. It has better priority handling than `setTimeout(fn, 0)` because the browser treats it as a higher priority continuation. For browsers that do not yet support it, a polyfill using `MessageChannel` provides equivalent behavior.

### Defer Non Critical JavaScript

Third party scripts for analytics, marketing automation, and chat widgets are among the most common sources of main thread contention during page load. Loading them with the `defer` attribute prevents them from blocking the parser. For scripts that are not needed at all until user interaction, lazy loading them on first interaction eliminates their load time impact entirely.

```tsx
// Lazy-load a heavy component only when needed
import { lazy, Suspense } from "react";

const HeavyAnalyticsDashboard = lazy(
  () => import("./HeavyAnalyticsDashboard")
);

function App() {
  const [showDashboard, setShowDashboard] = useState(false);

  return (
    <div>
      <button onClick={() => setShowDashboard(true)}>
        Open Dashboard
      </button>
      {showDashboard && (
        <Suspense fallback={<p>Loading...</p>}>
          <HeavyAnalyticsDashboard />
        </Suspense>
      )}
    </div>
  );
}
```

## Fixing Processing Time: Leaner Event Handlers

Event handlers that do too much work synchronously are the most common cause of poor processing time scores. The principles for fixing them:

### Separate Immediate Feedback from Background Work

Users need to see a visual response to their interaction immediately, even if the full business logic takes longer. Split event handlers into two parts: a synchronous update that gives instant visual feedback, and deferred work for the heavier processing.

```
function handleAddToCart(productId) {
  // 1. Give immediate visual feedback synchronously
  setCartButtonState("adding");

  // 2. Defer the heavy work to avoid blocking the interaction response
  setTimeout(() => {
    updateCartState(productId);
    recalculateTotals();
    trackAnalyticsEvent("add_to_cart", productId);
    setCartButtonState("added");
  }, 0);
}
```

### Avoid Synchronous DOM Queries in Event Handlers

Calling `getBoundingClientRect()`, `offsetWidth`, `scrollHeight`, or any other layout reading property inside an event handler forces the browser to perform a synchronous layout calculation before the handler can continue. This is known as forced synchronous layout or layout thrashing. Cache these values before the interaction or use `ResizeObserver` to track layout changes reactively.

```js
// Bad: forces layout on every click
function handleClick() {
  const height = element.getBoundingClientRect().height;
  updateUI(height);
}

// Better: cache the value, update reactively
const elementHeight = ref(0);

onMounted(() => {
  const observer = new ResizeObserver(entries => {
    elementHeight.value = entries[0].contentRect.height;
  });
  observer.observe(element.value);
});

function handleClick() {
  updateUI(elementHeight.value); // no layout forced
}
```

## Fixing Presentation Delay: Rendering Performance

Presentation delay is the most architectural of the three phases. The main causes:

### DOM Size

Large DOM trees with thousands of nodes slow down style recalculation and layout on every interaction that causes any visual change. The general guidance is to keep the DOM under 1,500 total nodes, under 32 levels of nesting, and under 60 children per parent node. For long lists, list virtualization renders only the visible subset of items, keeping the DOM small regardless of data size.

### Excessive CSS Recalculation

Complex CSS selectors with deep descendant combinators force the browser to recalculate styles for large portions of the DOM on every state change. Prefer class based state changes over attribute selectors, keep CSS specificity flat, and avoid selectors that match broad swaths of the document.

### Avoid Layout Triggering Properties in Animations

Animations that modify `width`, `height`, `top`, `left`, `margin`, or `padding` force a layout recalculation on every frame. Animating only `transform` and `opacity` allows the browser to run the animation on the compositor thread without touching layout, eliminating this cost entirely.

```css
/* Bad for INP: triggers layout on every frame */
.card:hover {
  width: 320px;
  margin-top: -4px;
}

/* Good for INP: compositor-thread only */
.card:hover {
  transform: scale(1.02) translateY(-4px);
  opacity: 0.95;
}
```

## React Specific Patterns

React applications have two patterns particularly relevant to INP that are worth calling out specifically.

### useTransition for Non Urgent Updates

React 18's `useTransition` allows you to mark state updates as non urgent, telling React it can interrupt them to process more pressing interactions like the one the user just triggered. This is the React idiomatic way to separate immediate feedback from expensive renders.

```jsx
import { useState, useTransition } from "react";

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

  const handleSearch = (e) => {
    // Synchronous: updates input immediately for visual feedback
    setQuery(e.target.value);

    // Deferred: the expensive results render can be interrupted
    startTransition(() => {
      setResults(searchDatabase(e.target.value));
    });
  };

  return (
    <div>
      <input value={query} onChange={handleSearch} />
      {isPending ? <p>Searching...</p> : <ResultsList results={results} />}
    </div>
  );
}
```

### Virtualize Long Lists

Rendering thousands of DOM nodes in a list is one of the most reliable ways to produce poor presentation delay scores. Libraries like TanStack Virtual (formerly react virtual) render only the visible rows plus a small buffer, keeping the DOM size manageable regardless of dataset size.

## Setting Performance Budgets

INP optimization is ongoing, not a one time fix. As codebases grow, new features add event handlers, third party scripts accumulate, and DOM size grows. The teams that maintain good INP scores over time do so by integrating performance budgets into their CI pipelines rather than treating performance as a periodic audit.

A practical setup uses Lighthouse CI with custom INP thresholds, failing builds that exceed defined limits. Combined with real user monitoring sending INP data to an analytics platform, this creates a feedback loop where regressions are caught before they reach a significant percentage of users.

## References

[web.dev: Interaction to Next Paint](https://web.dev/articles/inp)

[web.dev: How to Optimize INP](https://web.dev/explore/how-to-optimize-inp)

[CoreWebVitals.io: Interaction to Next Paint Complete Guide](https://www.corewebvitals.io/core-web-vitals/interaction-to-next-paint)

[Alphonso Labs: 10 Frontend Performance Trends](https://www.alphonsolabs.com/frontend-performance-trends-2026/)

[LinkGraph: INP Optimization Complete Guide](https://www.linkgraph.com/blog/interaction-to-next-paint-optimization/)

[Parachute Design: Interaction to Next Paint Practical Guide](https://parachutedesign.ca/blog/interaction-to-next-paint-inp/)

[BrowserStack: Understanding Interaction to Next Paint](https://www.browserstack.com/guide/interaction-to-next-paint-inp)
