Web Development

INP Optimization Guide 2026: Interaction Delays, Long Tasks & JavaScript Performance

By Reviewed by Hawrry Bhattarai
August 5, 2026 18 min read
Contents
TL;DR — the short answer

Fix INP in 2026 with this complete guide: diagnose long tasks, break up JavaScript, optimize React renders, remove third-party scripts, and use scheduler.yield().

13 min read · Web Development · Last updated July 2026

Quick answer: INP failures are almost always caused by JavaScript blocking the main thread — either during page startup (large bundle evaluation) or during interaction handling (heavy event handlers, third-party scripts, or over-rendering frameworks). Diagnose with Chrome DevTools Performance panel, then break up long tasks, defer third-party scripts, and reduce event handler work.

Introduction

Interaction to Next Paint is the youngest Core Web Vital and the most technically demanding to fix. Unlike LCP (which is primarily about resource delivery) or CLS (which is primarily about HTML and CSS), INP requires deep JavaScript performance work — profiling execution timelines, identifying bottlenecks in event handlers, and sometimes restructuring how frameworks like React or Vue process state changes.

INP replaced First Input Delay in March 2024 because FID was too easy to game: it only measured the delay before the browser processed your first click. INP measures every click, tap, and keypress throughout the page session and reports the worst-performing interaction (with statistical outlier trimming for very long sessions).

The practical impact: a React application where clicking a filter button triggers a 400ms re-render of a product grid will have a Poor INP — even if the first click on page load was instantaneous.

In this guide, you’ll learn:
– Exactly how INP is measured and what makes it harder to optimize than FID was
– How to diagnose INP failures with Chrome DevTools, the INP attribution library, and PageSpeed Insights
– The five main causes of poor INP with specific JavaScript-level fixes for each
– How to use scheduler.yield(), code splitting, and Web Workers to make interactions feel instantaneous

Table of Contents

  1. What INP Actually Measures
  2. INP vs FID: What Changed
  3. Diagnosing INP: Chrome DevTools and Attribution Library
  4. Cause 1: Long Tasks Blocking the Main Thread
  5. Cause 2: JavaScript Bundle Size and Startup Cost
  6. Cause 3: Heavy Event Handlers
  7. Cause 4: Third-Party Scripts Competing for the Main Thread
  8. Cause 5: React and Vue Rendering Performance
  9. Advanced Fix: scheduler.yield() and Task Yielding
  10. INP Diagnostic Widget
  11. JavaScript Performance Audit Checklist
  12. FAQ
  13. Conclusion

What INP Actually Measures

INP measures the time from a qualifying user interaction to the next visual update (paint) on screen. The timeline has four stages:

Interaction event fires
     ↓ [Input delay]
Browser picks up the event (main thread becomes available)
     ↓ [Processing time]  
Event handler runs (JavaScript executes)
     ↓ [Presentation delay]
Browser renders the response (style, layout, paint, composite)
     ↓
Next paint — INP stops the clock here

Input delay is caused by the main thread being busy with other work when the interaction fires. If a 200ms JavaScript task is running and a user clicks during it, they wait up to 200ms just to have their click registered.

Processing time is how long your event handler takes to run. A handler that modifies 500 DOM nodes, makes a synchronous fetch, or triggers a full React reconciliation can take 100–300ms.

Presentation delay is the time for the browser to convert JavaScript changes into visible pixels. Complex CSS (box-shadow, border-radius on large elements, filter effects), large DOM trees, and forced synchronous layouts all extend this.

Your INP score is the 98th percentile of all interaction durations across the page session (for pages with more than 50 interactions, the worst 2% are excluded). This means one catastrophically slow interaction — like clicking a dropdown that triggers a full page re-render — can dominate your INP score.

INP thresholds

Threshold Value
Good < 200ms
Needs Improvement 200ms – 500ms
Poor > 500ms

INP vs FID: What Changed

First Input Delay only measured the input delay portion of the very first interaction. A page could have fast FID (< 100ms) because the first click happened before any JavaScript ran, but subsequently have 500ms+ delays on every other interaction.

INP captures:
Every qualifying interaction (not just the first)
The full delay (input delay + processing time + presentation delay)
Across the whole session (including interactions deep in a page session when more JS has loaded)

The practical difference: FID could be passed by simply not blocking the main thread during initial page load. INP requires that all interactions throughout the session remain responsive. Sites with lazy-loaded JavaScript that initializes after user interaction, or React apps with expensive renders triggered by user input, often had excellent FID but now fail INP.

Diagnosing INP: Chrome DevTools and Attribution Library

Chrome DevTools Performance Panel

The most detailed INP diagnostic tool:

  1. Open Chrome DevTools → Performance tab
  2. Click “Start profiling and reload page” or just “Record”
  3. Interact with the page (click things, type, open menus)
  4. Click “Stop”
  5. In the timeline, look for the Long Tasks indicator (red triangles at the top of the Timings row)
  6. Click on a Long Task to see the flame chart — which function caused it

The flame chart shows you the exact JavaScript call stack that caused the long task. Look for:
– Large blocks of your own application code
– Third-party library execution (analytics, chat widgets)
– React reconciliation batches (shows as performUnitOfWork, renderWithHooks)

INP Attribution with the web-vitals library

import { onINP } from "web-vitals/attribution";

onINP((metric) => {
  const { interactionTarget, interactionType, inputDelay, processingDuration, presentationDelay } = metric.attribution;
  console.log("INP:", metric.value, "ms");
  console.log("Interaction on:", interactionTarget);
  console.log("Type:", interactionType);
  console.log("Input delay:", inputDelay);
  console.log("Processing:", processingDuration);
  console.log("Presentation:", presentationDelay);
});

This gives you the exact element the user interacted with (interactionTarget as a CSS selector), the three-phase breakdown, and the interaction type — letting you prioritize which interactions to optimize.

PageSpeed Insights INP diagnostics

PSI now shows INP data in the field data section (when CrUX data is available) and provides attribution in the “Diagnose performance issues” section showing which interaction type is the worst performer. For INP-specific attribution, the Chrome DevTools approach is more detailed.

Cause 1: Long Tasks Blocking the Main Thread

A “long task” is any JavaScript task that takes longer than 50ms. During a long task, the browser can’t process user interactions — they queue up and are handled only after the task completes.

Finding long tasks

In Chrome DevTools Performance panel, long tasks appear as red-highlighted sections at the top of the Main thread row. The width of the red bar represents how long the task blocked the main thread.

Common sources of long tasks:
– Initial JavaScript bundle evaluation (parsing + compiling a 500KB+ bundle)
– Synchronous DOM manipulation over large trees
– Complex regex operations on large strings
– Third-party script initialization

Breaking up long tasks with setTimeout

The classic approach to breaking up long tasks is setTimeout(fn, 0) — yielding control back to the browser between chunks of work:

// Before: one 400ms task that blocks all interaction
function processItems(items) {
  items.forEach(item => expensiveOperation(item)); // blocks for 400ms
}

// After: yielded in 50ms chunks
async function processItemsYielded(items) {
  const CHUNK_SIZE = 10;
  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE);
    chunk.forEach(item => expensiveOperation(item)); // 50ms of work
    await new Promise(resolve => setTimeout(resolve, 0)); // yield to browser
  }
}

Each await new Promise(resolve => setTimeout(resolve, 0)) yields control back to the browser’s task queue, allowing it to process any pending user interactions before continuing.

Cause 2: JavaScript Bundle Size and Startup Cost

Modern JavaScript applications ship large bundles that the browser must parse, compile, and evaluate before any interaction is possible. A 1MB JavaScript bundle can take 3–8 seconds to evaluate on a mid-range mobile device — and during that entire period, the main thread is blocked.

Measuring bundle impact

Chrome DevTools Coverage tab (Cmd+Shift+P → “Show Coverage”) shows which percentage of loaded JavaScript is actually executed during a page load. It’s common to find 40–70% of loaded JavaScript is unused on the initial page load.

Code splitting with dynamic import

// Before: loads all code upfront
import { ProductFilter } from "./ProductFilter";
import { SearchModal } from "./SearchModal";
import { RecommendationsEngine } from "./RecommendationsEngine";

// After: loads code only when needed
async function openFilter() {
  const { ProductFilter } = await import("./ProductFilter");
  const filter = new ProductFilter();
  filter.render();
}

async function openSearch() {
  const { SearchModal } = await import("./SearchModal");
  SearchModal.open();
}

With webpack, Vite, or Rollup, dynamic import() creates separate chunks that are only downloaded when that code path is actually triggered. A user who never opens the search modal never downloads the SearchModal component.

React lazy loading

import React, { lazy, Suspense } from "react";

// Lazy-loaded: only fetches when rendered
const HeavyChart = lazy(() => import("./HeavyChart"));
const ProductModal = lazy(() => import("./ProductModal"));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      {showChart && <HeavyChart data={data} />}
      {showModal && <ProductModal product={product} />}
    </Suspense>
  );
}

React.lazy() defers the download, parsing, and evaluation of the component until it’s first rendered. For heavy components that are conditionally shown (modals, charts, complex forms), this can significantly reduce initial bundle evaluation time.

Bundle analysis

Use webpack-bundle-analyzer or Vite’s built-in rollup visualizer to see your bundle composition. Common large dependencies:
– Moment.js (~67KB) → replace with date-fns (tree-shakeable) or Temporal API
– Lodash (full) → use individual lodash/function imports or native equivalents
– Full component libraries (Material UI, Ant Design) without tree-shaking configured

Cause 3: Heavy Event Handlers

Even without long tasks at startup, event handlers that do too much work cause high processing-time INP.

What heavy event handlers look like

// Poor INP: event handler does too much synchronous work
searchInput.addEventListener("keyup", (event) => {
  const query = event.target.value;
  const results = bigDataArray.filter(item =>   // synchronous filter of 10,000 items
    item.name.toLowerCase().includes(query.toLowerCase())
  );
  renderResults(results);   // synchronous DOM manipulation
  updateURL(query);         // synchronous history manipulation
  logAnalytics(query);      // synchronous analytics
});

Debouncing user input

// Debounced: only runs 200ms after the user stops typing
function debounce(fn, delay) {
  let timer;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

const handleSearch = debounce((event) => {
  const query = event.target.value;
  // Now only runs once after typing pauses — not on every keystroke
  fetchSearchResults(query).then(renderResults);
}, 200);

searchInput.addEventListener("keyup", handleSearch);

Deferring non-critical work out of event handlers

// Better: only do the critical visual update synchronously
button.addEventListener("click", (event) => {
  // Critical: update UI immediately so INP is fast
  button.classList.add("loading");
  button.textContent = "Processing...";

  // Defer: send analytics after the paint
  requestAnimationFrame(() => {
    requestIdleCallback(() => {
      analytics.track("button_clicked", { id: event.target.id });
    });
  });

  // Async: do the actual work asynchronously
  processOrderAsync().then(() => {
    button.textContent = "Done!";
    button.classList.remove("loading");
  });
});

The key insight: the browser paints after the event handler returns. Anything you defer out of the event handler (analytics calls, non-critical state updates, logging) reduces the processing time component of INP and lets the browser paint sooner.

Cause 4: Third-Party Scripts Competing for the Main Thread

Third-party scripts are the most overlooked cause of poor INP. Every tag manager tag, analytics library, A/B testing tool, live chat widget, and retargeting pixel runs JavaScript on your users’ browsers, competing for the same main thread that processes their interactions.

Quantifying third-party INP impact

The diagnostic approach:

  1. Open Chrome DevTools → Network tab
  2. Click the “Block request URL” option (right-click on any third-party request)
  3. Block all third-party origins one by one
  4. After each block, reload and measure INP with Chrome DevTools Performance panel
  5. When INP improves significantly after blocking a specific origin, you’ve found your culprit

Script loading strategies





Partytown: Running third-party scripts in Web Workers

Partytown is a library developed by Builder.io that relocates third-party scripts to a Web Worker, removing them entirely from the main thread. The worker runs in parallel, communicating with the main thread through a synchronous proxy.

// With Partytown, third-party scripts run off the main thread
import { partytownSnippet } from "@builder.io/partytown/integration";

// Configure which scripts Partytown should handle
partytown = {
  forward: ["dataLayer.push", "gtag"]
};

Scripts like Google Analytics 4, Facebook Pixel, and Google Tag Manager can be offloaded to Web Workers with Partytown, dramatically reducing main thread contention. Partytown is supported natively in Next.js (@next/third-parties) and Astro.

Cause 5: React and Vue Rendering Performance

Single-page applications built with React or Vue often fail INP because user interactions trigger expensive reconciliation or reactive updates that update too many DOM nodes synchronously.

Diagnosing React render performance

Install the React DevTools browser extension. In the Profiler tab, record a session and interact with the page. The flame chart shows every component that re-rendered, how long it took, and whether it was necessary. Look for:
– Components re-rendering without any relevant prop or state changes (“wasted renders”)
– Large subtrees re-rendering because a parent component’s state changed
– Lists that re-render all items when one changes

React performance fixes

React.memo — prevent wasted child renders:

// Without memo: re-renders every time parent renders, regardless of prop change
const ProductCard = ({ product, onAddToCart }) => (
  <div className="card">
    <h3>{product.name}</h3>
    <button onClick={() => onAddToCart(product.id)}>Add to Cart</button>
  </div>
);

// With memo: only re-renders if product or onAddToCart reference changes
const ProductCard = React.memo(({ product, onAddToCart }) => (
  <div className="card">
    <h3>{product.name}</h3>
    <button onClick={() => onAddToCart(product.id)}>Add to Cart</button>
  </div>
));

useCallback — stable function references:

function ProductList({ products }) {
  // Without useCallback: new function reference every render → all ProductCards re-render
  const handleAddToCart = (id) => addToCart(id);

  // With useCallback: stable reference → ProductCards with memo don't re-render
  const handleAddToCart = useCallback((id) => addToCart(id), []);

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

useMemo — avoid expensive recalculations:

function FilteredProductList({ products, filterQuery, sortOrder }) {
  // Without useMemo: recalculates on every render, even if inputs didn't change
  const sortedFilteredProducts = products
    .filter(p => p.name.includes(filterQuery))
    .sort((a, b) => sortOrder === "asc" ? a.price - b.price : b.price - a.price);

  // With useMemo: only recalculates when products, filterQuery, or sortOrder change
  const sortedFilteredProducts = useMemo(() => products
    .filter(p => p.name.includes(filterQuery))
    .sort((a, b) => sortOrder === "asc" ? a.price - b.price : b.price - a.price),
    [products, filterQuery, sortOrder]
  );
}

React 18 concurrent features for INP:

import { useTransition, startTransition } from "react";

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

  function handleSearch(e) {
    // Immediate update: input stays responsive (low priority)
    setQuery(e.target.value);

    // Deferred update: search results can be interrupted if user types again
    startTransition(() => {
      setResults(searchProducts(e.target.value));
    });
  }
}

startTransition marks a state update as non-urgent, allowing React to interrupt it if a higher-priority update (like a new keystroke) arrives. This keeps the input responsive (fast INP) even while a slow results render is happening.

Advanced Fix: scheduler.yield() and Task Yielding

scheduler.yield() is a newer browser API (available in Chrome 115+) that provides a more direct way to yield the main thread than setTimeout(fn, 0):

async function processLargeDataSet(items) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i]);

    // Yield to the browser every 5 items
    if (i % 5 === 0) {
      if ("scheduler" in window && "yield" in scheduler) {
        await scheduler.yield(); // Modern browsers: more efficient yield
      } else {
        await new Promise(resolve => setTimeout(resolve, 0)); // Fallback
      }
    }
  }
}

The advantage over setTimeout: scheduler.yield() is specifically designed for yielding within tasks. It respects the browser’s prioritization model and returns control faster than setTimeout(fn, 0) (which has a minimum ~4ms delay in modern browsers due to timer throttling).

Web Workers for CPU-heavy work

For truly expensive computations (image processing, large data transformations, complex filtering), move the work to a Web Worker entirely:

// main.js
const worker = new Worker("./data-worker.js");

worker.onmessage = (event) => {
  // Results come back after computation, main thread was never blocked
  displayResults(event.data.results);
};

function searchProducts(query) {
  // Offload to worker: main thread stays free for interactions
  worker.postMessage({ type: "search", query });
}

// data-worker.js
self.onmessage = (event) => {
  const { type, query } = event.data;
  if (type === "search") {
    const results = allProducts.filter(p =>
      p.name.toLowerCase().includes(query.toLowerCase())
    );
    self.postMessage({ results });
  }
};

The worker runs in a separate thread — it cannot block the main thread at all. The main thread sends data to the worker, continues handling interactions while the worker computes, and receives results when ready.


INP Diagnostic Widget

⚡ INP Diagnostic Guide

Answer questions about your site to identify your most likely INP bottleneck and targeted fix.


JavaScript Performance Audit Checklist

🔧 JavaScript Performance Audit Checklist

Check each item you’ve implemented or verified. Use before and after performance optimization sprints.

0 of 16 checks complete
Bundle & Loading




Third-Party Scripts



Event Handlers




React / Vue Rendering





FAQ

Q: My site has good FID but poor INP. Why?

FID only measured the input delay for the very first user interaction after page load. INP measures all interactions across the session, including the full processing time (not just input delay). A page where the first click fires before any JavaScript runs can have excellent FID (< 100ms) but terrible INP if subsequent clicks trigger 400ms+ React re-renders.

Q: What’s the fastest way to identify the interaction causing my high INP?

Install the web-vitals library with attribution: import { onINP } from "web-vitals/attribution" and log metric.attribution.interactionTarget. This tells you which CSS selector the user interacted with when INP was recorded. Then profile that specific element’s click handler in Chrome DevTools Performance panel.

Q: Does server response time affect INP?

Indirectly. Network requests made inside event handlers (fetch, XHR) don’t block the main thread — they’re asynchronous. However, if the event handler synchronously waits for a response (using await in a way that prevents the visual update), it extends presentation delay. The best pattern: trigger the visual update (loading state) synchronously, then await the network response for the final update.

Q: Is Partytown safe to use for Google Analytics 4?

Partytown has good GA4 support via its forward configuration. The main caveat: GA4’s session and user tracking rely on cookies set by the main document — Partytown handles this through a synchronous proxy that bridges worker and main thread communication. It’s used in production by many large sites. Test GA4 data accuracy in your analytics dashboard after implementation.

Q: Can I improve INP without code changes by using a CDN or better hosting?

Faster servers reduce Time to First Byte and improve LCP, but they don’t directly improve INP — which is measured client-side on the user’s device. A CDN serves files faster, reducing how long JavaScript takes to download (improving startup INP), but once JavaScript is running, INP is determined by the user’s CPU performance and your JavaScript efficiency. The only server-side contribution is reducing the JavaScript payload that needs to be evaluated.

Conclusion

INP optimization is the most technically demanding of the Core Web Vitals improvements, but it’s also the most impactful for perceived responsiveness. Users notice sluggish interactions immediately — it’s the difference between an app that feels native and one that feels “webby” in the worst sense.

Start with diagnostics: the web-vitals attribution library in production, Chrome DevTools Performance panel for the specific interaction that’s slow. Then work through the hierarchy: bundle size first, third-party scripts second, event handler efficiency third, and framework rendering optimization fourth. Most sites find 80% of their INP problem in the first two categories.

For React SPAs with complex INP issues, or for sites where third-party script optimization requires careful GTM and analytics restructuring, the Ignited Nepal team delivers targeted JavaScript performance engineering with documented results.

Talk to Ignited Nepal’s Web Development team

Written by the Ignited Nepal team. ignitednepal.com

NR

Article by

Niraj Raut

Head of Search at Ignited Nepal. Drove 340% organic traffic growth for EzyDog (Australia), 4× revenue for The Turf Man (Australia), and 120% month-on-month traffic growth for ThemeGrill (Nepal). Keynote speaker at WordCamp Nepal 2023 and verified WordPress.org open-source contributor.